Spaces:
Sleeping
Sleeping
Commit
·
cfea739
1
Parent(s):
3b42d8f
init commit
Browse files- .cdsapirc +2 -0
- .dockerignore +55 -0
- .gitignore +11 -0
- Dockerfile +52 -0
- README copy.md +5 -0
- README_HF.md +27 -0
- app.py +565 -0
- app_readme.md +38 -0
- cams_downloader.py +355 -0
- constants.py +114 -0
- data_processor.py +468 -0
- deploy.sh +24 -0
- interactive_plot_generator.py +347 -0
- plot_generator.py +216 -0
- requirements.txt +14 -0
- templates/index.html +281 -0
- templates/plot.html +423 -0
- templates/variables.html +508 -0
.cdsapirc
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
url: https://ads.atmosphere.copernicus.eu/api
|
| 2 |
+
key: 4492fecf-e164-45ed-9d8e-fc86ca282600
|
.dockerignore
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git
|
| 2 |
+
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
|
| 5 |
+
# Python
|
| 6 |
+
__pycache__
|
| 7 |
+
*.pyc
|
| 8 |
+
*.pyo
|
| 9 |
+
*.pyd
|
| 10 |
+
.Python
|
| 11 |
+
env
|
| 12 |
+
pip-log.txt
|
| 13 |
+
pip-delete-this-directory.txt
|
| 14 |
+
.tox
|
| 15 |
+
.coverage
|
| 16 |
+
.coverage.*
|
| 17 |
+
.cache
|
| 18 |
+
nosetests.xml
|
| 19 |
+
coverage.xml
|
| 20 |
+
*.cover
|
| 21 |
+
*.log
|
| 22 |
+
.idea
|
| 23 |
+
.mypy_cache
|
| 24 |
+
.pytest_cache
|
| 25 |
+
.hypothesis
|
| 26 |
+
|
| 27 |
+
# OS
|
| 28 |
+
.DS_Store
|
| 29 |
+
.DS_Store?
|
| 30 |
+
._*
|
| 31 |
+
.Spotlight-V100
|
| 32 |
+
.Trashes
|
| 33 |
+
ehthumbs.db
|
| 34 |
+
Thumbs.db
|
| 35 |
+
|
| 36 |
+
# Project specific
|
| 37 |
+
downloads/*
|
| 38 |
+
!downloads/.gitkeep
|
| 39 |
+
plots/*
|
| 40 |
+
!plots/.gitkeep
|
| 41 |
+
uploads/*
|
| 42 |
+
!uploads/.gitkeep
|
| 43 |
+
|
| 44 |
+
# Documentation
|
| 45 |
+
*.md
|
| 46 |
+
!app_readme.md
|
| 47 |
+
|
| 48 |
+
# Development
|
| 49 |
+
.vscode
|
| 50 |
+
.env
|
| 51 |
+
.cdsapirc
|
| 52 |
+
|
| 53 |
+
# Temporary files
|
| 54 |
+
*.tmp
|
| 55 |
+
*.temp
|
.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.DS_Store
|
| 2 |
+
__pycache__/
|
| 3 |
+
|
| 4 |
+
# Any file in these folders should be ignored.
|
| 5 |
+
static/
|
| 6 |
+
plots/
|
| 7 |
+
uploads/
|
| 8 |
+
downloads/
|
| 9 |
+
shapefiles/
|
| 10 |
+
|
| 11 |
+
|
Dockerfile
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.11 slim image for better compatibility with scientific packages
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install system dependencies required for the scientific packages
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
build-essential \
|
| 10 |
+
libproj-dev \
|
| 11 |
+
proj-data \
|
| 12 |
+
proj-bin \
|
| 13 |
+
libgeos-dev \
|
| 14 |
+
libgdal-dev \
|
| 15 |
+
gdal-bin \
|
| 16 |
+
libspatialindex-dev \
|
| 17 |
+
libffi-dev \
|
| 18 |
+
git \
|
| 19 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 20 |
+
|
| 21 |
+
# Set environment variables for GDAL
|
| 22 |
+
ENV CPLUS_INCLUDE_PATH=/usr/include/gdal
|
| 23 |
+
ENV C_INCLUDE_PATH=/usr/include/gdal
|
| 24 |
+
ENV GDAL_DATA=/usr/share/gdal
|
| 25 |
+
|
| 26 |
+
# Copy requirements first for better caching
|
| 27 |
+
COPY requirements.txt .
|
| 28 |
+
|
| 29 |
+
# Install Python dependencies
|
| 30 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 31 |
+
|
| 32 |
+
# Copy the application code
|
| 33 |
+
COPY . .
|
| 34 |
+
|
| 35 |
+
# Create necessary directories
|
| 36 |
+
RUN mkdir -p uploads downloads/extracted plots shapefiles static templates
|
| 37 |
+
|
| 38 |
+
# Set environment variables for Flask
|
| 39 |
+
ENV FLASK_APP=app.py
|
| 40 |
+
ENV FLASK_ENV=production
|
| 41 |
+
ENV PYTHONUNBUFFERED=1
|
| 42 |
+
|
| 43 |
+
# Expose the port that Hugging Face Spaces expects
|
| 44 |
+
EXPOSE 7860
|
| 45 |
+
|
| 46 |
+
# Create a non-root user for security
|
| 47 |
+
RUN useradd -m -u 1000 user && chown -R user:user /app
|
| 48 |
+
USER user
|
| 49 |
+
|
| 50 |
+
# Command to run the application
|
| 51 |
+
# Note: Hugging Face Spaces expects the app to run on port 7860
|
| 52 |
+
CMD ["python", "-c", "import sys; sys.path.append('.'); from app import app; app.run(host='0.0.0.0', port=7860, debug=False)"]
|
README copy.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
### CAMS - Air Pollution Dashboard
|
| 2 |
+
|
| 3 |
+
A web-based visualization tool for CAMS (Copernicus Atmosphere Monitoring Service) air pollution data over India. Upload NetCDF files or download data by date, select pollutants (PM2.5, NO₂, O₃, etc.), choose pressure levels for atmospheric variables, and generate interactive maps with customizable color themes.
|
| 4 |
+
|
| 5 |
+
> You will need the `.cdsapirc` file set up for downloading data from CDS(Client Data Store). Also make sure to make a seperate viirtual environment for this project and download all the dependencies from `requirements.txt` file.
|
README_HF.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CAMS Air Pollution Dashboard
|
| 2 |
+
|
| 3 |
+
A comprehensive web application for visualizing atmospheric composition data from the Copernicus Atmosphere Monitoring Service (CAMS).
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- 🌍 Interactive air pollution maps for India
|
| 8 |
+
- 📊 Multiple pollutant visualization (PM2.5, PM10, NO2, O3, CO, etc.)
|
| 9 |
+
- 🎨 Various color themes for data visualization
|
| 10 |
+
- 📁 Support for NetCDF file uploads
|
| 11 |
+
- 🌐 Direct CAMS data download integration
|
| 12 |
+
- 📈 Statistical analysis and hover information
|
| 13 |
+
|
| 14 |
+
## Usage
|
| 15 |
+
|
| 16 |
+
1. Upload your own NetCDF files or download CAMS data for specific dates
|
| 17 |
+
2. Select variables, pressure levels, and time points
|
| 18 |
+
3. Generate static or interactive pollution maps
|
| 19 |
+
4. Explore air quality data with detailed statistics
|
| 20 |
+
|
| 21 |
+
## Data Source
|
| 22 |
+
|
| 23 |
+
This application uses data from the Copernicus Atmosphere Monitoring Service (CAMS), which provides global atmospheric composition forecasts and analyses.
|
| 24 |
+
|
| 25 |
+
## License
|
| 26 |
+
|
| 27 |
+
This project is open source and available under the MIT License.
|
app.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Flask web application for CAMS air pollution visualization
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import json
|
| 5 |
+
import traceback
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from werkzeug.utils import secure_filename
|
| 10 |
+
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_file
|
| 11 |
+
|
| 12 |
+
# Import our custom modules
|
| 13 |
+
from data_processor import NetCDFProcessor, analyze_netcdf_file
|
| 14 |
+
from plot_generator import IndiaMapPlotter
|
| 15 |
+
from interactive_plot_generator import InteractiveIndiaMapPlotter
|
| 16 |
+
from cams_downloader import CAMSDownloader
|
| 17 |
+
from constants import ALLOWED_EXTENSIONS, MAX_FILE_SIZE, COLOR_THEMES
|
| 18 |
+
|
| 19 |
+
app = Flask(__name__)
|
| 20 |
+
app.secret_key = 'your-secret-key-change-this-in-production' # Change this!
|
| 21 |
+
app.config['DEBUG'] = False # Explicitly disable debug mode
|
| 22 |
+
|
| 23 |
+
# Add JSON filter for templates
|
| 24 |
+
import json
|
| 25 |
+
app.jinja_env.filters['tojson'] = json.dumps
|
| 26 |
+
|
| 27 |
+
# Configure upload settings
|
| 28 |
+
app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE
|
| 29 |
+
app.config['UPLOAD_FOLDER'] = 'uploads'
|
| 30 |
+
|
| 31 |
+
# Initialize our services
|
| 32 |
+
downloader = CAMSDownloader()
|
| 33 |
+
plotter = IndiaMapPlotter()
|
| 34 |
+
interactive_plotter = InteractiveIndiaMapPlotter()
|
| 35 |
+
|
| 36 |
+
# Ensure directories exist
|
| 37 |
+
for directory in ['uploads', 'downloads', 'plots', 'templates', 'static']:
|
| 38 |
+
Path(directory).mkdir(exist_ok=True)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def allowed_file(filename):
|
| 42 |
+
"""Check if file extension is allowed"""
|
| 43 |
+
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def str_to_bool(value):
|
| 47 |
+
"""Convert string representation to boolean"""
|
| 48 |
+
if isinstance(value, bool):
|
| 49 |
+
return value
|
| 50 |
+
if isinstance(value, str):
|
| 51 |
+
return value.lower() in ('true', '1', 'yes', 'on')
|
| 52 |
+
return bool(value)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@app.route('/')
|
| 56 |
+
def index():
|
| 57 |
+
"""Main page - file upload or date selection"""
|
| 58 |
+
downloaded_files = downloader.list_downloaded_files()
|
| 59 |
+
# List files in uploads and downloads/extracted
|
| 60 |
+
upload_files = sorted(
|
| 61 |
+
[f for f in Path(app.config['UPLOAD_FOLDER']).glob('*') if f.is_file()],
|
| 62 |
+
key=lambda x: x.stat().st_mtime, reverse=True
|
| 63 |
+
)
|
| 64 |
+
extracted_files = sorted(
|
| 65 |
+
[f for f in Path('downloads/extracted').glob('*') if f.is_file()],
|
| 66 |
+
key=lambda x: x.stat().st_mtime, reverse=True
|
| 67 |
+
)
|
| 68 |
+
# Prepare for template: list of dicts with name and type
|
| 69 |
+
recent_files = [
|
| 70 |
+
{'name': f.name, 'type': 'upload'} for f in upload_files
|
| 71 |
+
] + [
|
| 72 |
+
{'name': f.name, 'type': 'download'} for f in extracted_files
|
| 73 |
+
]
|
| 74 |
+
current_date = datetime.now().strftime('%Y-%m-%d')
|
| 75 |
+
return render_template(
|
| 76 |
+
'index.html',
|
| 77 |
+
downloaded_files=downloaded_files,
|
| 78 |
+
cds_ready=downloader.is_client_ready(),
|
| 79 |
+
current_date=current_date,
|
| 80 |
+
recent_files=recent_files
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@app.route('/upload', methods=['POST'])
|
| 85 |
+
def upload_file():
|
| 86 |
+
"""Handle file upload"""
|
| 87 |
+
if 'file' not in request.files:
|
| 88 |
+
flash('No file selected', 'error')
|
| 89 |
+
return redirect(request.url)
|
| 90 |
+
|
| 91 |
+
file = request.files['file']
|
| 92 |
+
if file.filename == '':
|
| 93 |
+
flash('No file selected', 'error')
|
| 94 |
+
return redirect(request.url)
|
| 95 |
+
|
| 96 |
+
if file and allowed_file(file.filename):
|
| 97 |
+
try:
|
| 98 |
+
filename = secure_filename(file.filename)
|
| 99 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
| 100 |
+
filename = f"{timestamp}_{filename}"
|
| 101 |
+
filepath = Path(app.config['UPLOAD_FOLDER']) / filename
|
| 102 |
+
|
| 103 |
+
file.save(str(filepath))
|
| 104 |
+
flash(f'File uploaded successfully: {filename}', 'success')
|
| 105 |
+
|
| 106 |
+
return redirect(url_for('analyze_file', filename=filename))
|
| 107 |
+
|
| 108 |
+
except Exception as e:
|
| 109 |
+
flash(f'Error uploading file: {str(e)}', 'error')
|
| 110 |
+
return redirect(url_for('index'))
|
| 111 |
+
else:
|
| 112 |
+
flash('Invalid file type. Please upload .nc or .zip files.', 'error')
|
| 113 |
+
return redirect(url_for('index'))
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@app.route('/download_date', methods=['POST'])
|
| 117 |
+
def download_date():
|
| 118 |
+
"""Handle date-based download"""
|
| 119 |
+
date_str = request.form.get('date')
|
| 120 |
+
|
| 121 |
+
if not date_str:
|
| 122 |
+
flash('Please select a date', 'error')
|
| 123 |
+
return redirect(url_for('index'))
|
| 124 |
+
|
| 125 |
+
# --- Backend Validation Logic ---
|
| 126 |
+
try:
|
| 127 |
+
selected_date = datetime.strptime(date_str, '%Y-%m-%d')
|
| 128 |
+
start_date = datetime(2015, 1, 1)
|
| 129 |
+
end_date = datetime.now()
|
| 130 |
+
|
| 131 |
+
if not (start_date <= selected_date <= end_date):
|
| 132 |
+
flash(f'Invalid date. Please select a date between {start_date.strftime("%Y-%m-%d")} and today.', 'error')
|
| 133 |
+
return redirect(url_for('index'))
|
| 134 |
+
|
| 135 |
+
except ValueError:
|
| 136 |
+
flash('Invalid date format. Please use YYYY-MM-DD.', 'error')
|
| 137 |
+
return redirect(url_for('index'))
|
| 138 |
+
|
| 139 |
+
# --- End of Validation Logic ---
|
| 140 |
+
|
| 141 |
+
if not downloader.is_client_ready():
|
| 142 |
+
flash('CDS API not configured. Please check your .cdsapirc file.', 'error')
|
| 143 |
+
return redirect(url_for('index'))
|
| 144 |
+
|
| 145 |
+
try:
|
| 146 |
+
# Download CAMS data
|
| 147 |
+
zip_path = downloader.download_cams_data(date_str)
|
| 148 |
+
|
| 149 |
+
# Extract the files
|
| 150 |
+
extracted_files = downloader.extract_cams_files(zip_path)
|
| 151 |
+
|
| 152 |
+
flash(f'CAMS data downloaded successfully for {date_str}', 'success')
|
| 153 |
+
|
| 154 |
+
# Analyze the extracted files
|
| 155 |
+
if 'surface' in extracted_files:
|
| 156 |
+
filename = Path(extracted_files['surface']).name
|
| 157 |
+
return redirect(url_for('analyze_file', filename=filename, is_download='true'))
|
| 158 |
+
elif 'atmospheric' in extracted_files:
|
| 159 |
+
filename = Path(extracted_files['atmospheric']).name
|
| 160 |
+
return redirect(url_for('analyze_file', filename=filename, is_download='true'))
|
| 161 |
+
else:
|
| 162 |
+
# Use the first available file
|
| 163 |
+
first_file = list(extracted_files.values())[0]
|
| 164 |
+
filename = Path(first_file).name
|
| 165 |
+
return redirect(url_for('analyze_file', filename=filename, is_download='true'))
|
| 166 |
+
|
| 167 |
+
except Exception as e:
|
| 168 |
+
flash(f'Error downloading CAMS data: {str(e)}', 'error')
|
| 169 |
+
return redirect(url_for('index'))
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@app.route('/analyze/<filename>')
|
| 173 |
+
def analyze_file(filename):
|
| 174 |
+
"""Analyze uploaded file and show variable selection"""
|
| 175 |
+
is_download_param = request.args.get('is_download', 'false')
|
| 176 |
+
is_download = str_to_bool(is_download_param)
|
| 177 |
+
|
| 178 |
+
try:
|
| 179 |
+
# Determine file path
|
| 180 |
+
if is_download:
|
| 181 |
+
file_path = Path('downloads/extracted') / filename
|
| 182 |
+
else:
|
| 183 |
+
file_path = Path(app.config['UPLOAD_FOLDER']) / filename
|
| 184 |
+
|
| 185 |
+
if not file_path.exists():
|
| 186 |
+
flash('File not found', 'error')
|
| 187 |
+
return redirect(url_for('index'))
|
| 188 |
+
|
| 189 |
+
# Analyze the file
|
| 190 |
+
analysis = analyze_netcdf_file(str(file_path))
|
| 191 |
+
|
| 192 |
+
if not analysis['success']:
|
| 193 |
+
flash(f'Error analyzing file: {analysis["error"]}', 'error')
|
| 194 |
+
return redirect(url_for('index'))
|
| 195 |
+
|
| 196 |
+
if analysis['total_variables'] == 0:
|
| 197 |
+
flash('No air pollution variables found in the file', 'warning')
|
| 198 |
+
return redirect(url_for('index'))
|
| 199 |
+
|
| 200 |
+
# Process variables for template
|
| 201 |
+
variables = []
|
| 202 |
+
for var_name, var_info in analysis['detected_variables'].items():
|
| 203 |
+
variables.append({
|
| 204 |
+
'name': var_name,
|
| 205 |
+
'display_name': var_info['name'],
|
| 206 |
+
'type': var_info['type'],
|
| 207 |
+
'units': var_info['units'],
|
| 208 |
+
'shape': var_info['shape']
|
| 209 |
+
})
|
| 210 |
+
|
| 211 |
+
return render_template('variables.html',
|
| 212 |
+
filename=filename,
|
| 213 |
+
variables=variables,
|
| 214 |
+
color_themes=COLOR_THEMES,
|
| 215 |
+
is_download=is_download)
|
| 216 |
+
|
| 217 |
+
except Exception as e:
|
| 218 |
+
flash(f'Error analyzing file: {str(e)}', 'error')
|
| 219 |
+
return redirect(url_for('index'))
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
@app.route('/get_pressure_levels/<filename>/<variable>')
|
| 223 |
+
def get_pressure_levels(filename, variable):
|
| 224 |
+
"""AJAX endpoint to get pressure levels for atmospheric variables"""
|
| 225 |
+
try:
|
| 226 |
+
is_download_param = request.args.get('is_download', 'false')
|
| 227 |
+
is_download = str_to_bool(is_download_param)
|
| 228 |
+
|
| 229 |
+
print(f"is_download: {is_download} (type: {type(is_download)})")
|
| 230 |
+
|
| 231 |
+
# Determine file path
|
| 232 |
+
if is_download:
|
| 233 |
+
file_path = Path('downloads/extracted') / filename
|
| 234 |
+
print("Using downloaded file path")
|
| 235 |
+
else:
|
| 236 |
+
file_path = Path(app.config['UPLOAD_FOLDER']) / filename
|
| 237 |
+
print("Using upload file path")
|
| 238 |
+
|
| 239 |
+
print(f"File path: {file_path}")
|
| 240 |
+
|
| 241 |
+
processor = NetCDFProcessor(str(file_path))
|
| 242 |
+
processor.load_dataset()
|
| 243 |
+
processor.detect_variables()
|
| 244 |
+
|
| 245 |
+
pressure_levels = processor.get_available_pressure_levels(variable)
|
| 246 |
+
processor.close()
|
| 247 |
+
|
| 248 |
+
return jsonify({
|
| 249 |
+
'success': True,
|
| 250 |
+
'pressure_levels': pressure_levels
|
| 251 |
+
})
|
| 252 |
+
|
| 253 |
+
except Exception as e:
|
| 254 |
+
return jsonify({
|
| 255 |
+
'success': False,
|
| 256 |
+
'error': str(e)
|
| 257 |
+
})
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
@app.route('/get_available_times/<filename>/<variable>')
|
| 261 |
+
def get_available_times(filename, variable):
|
| 262 |
+
"""AJAX endpoint to get available timestamps for a variable"""
|
| 263 |
+
try:
|
| 264 |
+
is_download_param = request.args.get('is_download', 'false')
|
| 265 |
+
is_download = str_to_bool(is_download_param)
|
| 266 |
+
|
| 267 |
+
# Determine file path
|
| 268 |
+
if is_download:
|
| 269 |
+
file_path = Path('downloads/extracted') / filename
|
| 270 |
+
else:
|
| 271 |
+
file_path = Path(app.config['UPLOAD_FOLDER']) / filename
|
| 272 |
+
|
| 273 |
+
processor = NetCDFProcessor(str(file_path))
|
| 274 |
+
processor.load_dataset()
|
| 275 |
+
processor.detect_variables()
|
| 276 |
+
|
| 277 |
+
available_times = processor.get_available_times(variable)
|
| 278 |
+
processor.close()
|
| 279 |
+
|
| 280 |
+
# Format times for display
|
| 281 |
+
formatted_times = []
|
| 282 |
+
for i, time_val in enumerate(available_times):
|
| 283 |
+
formatted_times.append({
|
| 284 |
+
'index': i,
|
| 285 |
+
'value': str(time_val),
|
| 286 |
+
'display': time_val.strftime('%Y-%m-%d %H:%M') if hasattr(time_val, 'strftime') else str(time_val)
|
| 287 |
+
})
|
| 288 |
+
|
| 289 |
+
return jsonify({
|
| 290 |
+
'success': True,
|
| 291 |
+
'times': formatted_times
|
| 292 |
+
})
|
| 293 |
+
|
| 294 |
+
except Exception as e:
|
| 295 |
+
return jsonify({
|
| 296 |
+
'success': False,
|
| 297 |
+
'error': str(e)
|
| 298 |
+
})
|
| 299 |
+
|
| 300 |
+
@app.route('/visualize', methods=['POST'])
|
| 301 |
+
def visualize():
|
| 302 |
+
"""Generate and display the pollution map"""
|
| 303 |
+
try:
|
| 304 |
+
filename = request.form.get('filename')
|
| 305 |
+
variable = request.form.get('variable')
|
| 306 |
+
color_theme = request.form.get('color_theme', 'viridis')
|
| 307 |
+
pressure_level = request.form.get('pressure_level')
|
| 308 |
+
is_download_param = request.form.get('is_download', 'false')
|
| 309 |
+
is_download = str_to_bool(is_download_param)
|
| 310 |
+
|
| 311 |
+
if not filename or not variable:
|
| 312 |
+
flash('Missing required parameters', 'error')
|
| 313 |
+
return redirect(url_for('index'))
|
| 314 |
+
|
| 315 |
+
# Determine file path
|
| 316 |
+
if is_download:
|
| 317 |
+
file_path = Path('downloads/extracted') / filename
|
| 318 |
+
else:
|
| 319 |
+
file_path = Path(app.config['UPLOAD_FOLDER']) / filename
|
| 320 |
+
|
| 321 |
+
if not file_path.exists():
|
| 322 |
+
flash('File not found', 'error')
|
| 323 |
+
return redirect(url_for('index'))
|
| 324 |
+
|
| 325 |
+
# Process the data
|
| 326 |
+
processor = NetCDFProcessor(str(file_path))
|
| 327 |
+
processor.load_dataset()
|
| 328 |
+
processor.detect_variables()
|
| 329 |
+
|
| 330 |
+
# Convert pressure level to float if provided
|
| 331 |
+
pressure_level_val = None
|
| 332 |
+
if pressure_level and pressure_level != 'None':
|
| 333 |
+
try:
|
| 334 |
+
pressure_level_val = float(pressure_level)
|
| 335 |
+
except ValueError:
|
| 336 |
+
pressure_level_val = None
|
| 337 |
+
|
| 338 |
+
time_index_val = request.form.get('time_index')
|
| 339 |
+
# Extract data
|
| 340 |
+
data_values, metadata = processor.extract_data(
|
| 341 |
+
variable,
|
| 342 |
+
time_index = int(time_index_val) if time_index_val and time_index_val != 'None' else 0,
|
| 343 |
+
pressure_level=pressure_level_val
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
# Generate plot
|
| 347 |
+
plot_path = plotter.create_india_map(
|
| 348 |
+
data_values,
|
| 349 |
+
metadata,
|
| 350 |
+
color_theme=color_theme,
|
| 351 |
+
save_plot=True
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
processor.close()
|
| 355 |
+
|
| 356 |
+
if plot_path:
|
| 357 |
+
plot_filename = Path(plot_path).name
|
| 358 |
+
|
| 359 |
+
# Prepare metadata for display
|
| 360 |
+
plot_info = {
|
| 361 |
+
'variable': metadata.get('display_name', 'Unknown Variable'),
|
| 362 |
+
'units': metadata.get('units', ''),
|
| 363 |
+
'shape': str(metadata.get('shape', 'Unknown')),
|
| 364 |
+
'pressure_level': metadata.get('pressure_level'),
|
| 365 |
+
'color_theme': COLOR_THEMES.get(color_theme, color_theme),
|
| 366 |
+
'generated_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
| 367 |
+
'data_range': {
|
| 368 |
+
'min': float(f"{data_values.min():.3f}") if hasattr(data_values, 'min') and not data_values.min() is None else 0,
|
| 369 |
+
'max': float(f"{data_values.max():.3f}") if hasattr(data_values, 'max') and not data_values.max() is None else 0,
|
| 370 |
+
'mean': float(f"{data_values.mean():.3f}") if hasattr(data_values, 'mean') and not data_values.mean() is None else 0
|
| 371 |
+
}
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
print(f"Plot info prepared: {plot_info}")
|
| 375 |
+
|
| 376 |
+
return render_template('plot.html',
|
| 377 |
+
plot_filename=plot_filename,
|
| 378 |
+
plot_info=plot_info)
|
| 379 |
+
else:
|
| 380 |
+
flash('Error generating plot', 'error')
|
| 381 |
+
return redirect(url_for('index'))
|
| 382 |
+
|
| 383 |
+
except Exception as e:
|
| 384 |
+
flash(f'Error creating visualization: {str(e)}', 'error')
|
| 385 |
+
print(f"Full error: {traceback.format_exc()}")
|
| 386 |
+
return redirect(url_for('index'))
|
| 387 |
+
|
| 388 |
+
@app.route('/visualize_interactive', methods=['POST'])
|
| 389 |
+
def visualize_interactive():
|
| 390 |
+
"""Generate and display the interactive pollution map"""
|
| 391 |
+
try:
|
| 392 |
+
filename = request.form.get('filename')
|
| 393 |
+
variable = request.form.get('variable')
|
| 394 |
+
color_theme = request.form.get('color_theme', 'viridis')
|
| 395 |
+
pressure_level = request.form.get('pressure_level')
|
| 396 |
+
is_download_param = request.form.get('is_download', 'false')
|
| 397 |
+
is_download = str_to_bool(is_download_param)
|
| 398 |
+
|
| 399 |
+
if not filename or not variable:
|
| 400 |
+
flash('Missing required parameters', 'error')
|
| 401 |
+
return redirect(url_for('index'))
|
| 402 |
+
|
| 403 |
+
# Determine file path
|
| 404 |
+
if is_download:
|
| 405 |
+
file_path = Path('downloads/extracted') / filename
|
| 406 |
+
else:
|
| 407 |
+
file_path = Path(app.config['UPLOAD_FOLDER']) / filename
|
| 408 |
+
|
| 409 |
+
if not file_path.exists():
|
| 410 |
+
flash('File not found', 'error')
|
| 411 |
+
return redirect(url_for('index'))
|
| 412 |
+
|
| 413 |
+
# Process the data
|
| 414 |
+
processor = NetCDFProcessor(str(file_path))
|
| 415 |
+
processor.load_dataset()
|
| 416 |
+
processor.detect_variables()
|
| 417 |
+
|
| 418 |
+
# Convert pressure level to float if provided
|
| 419 |
+
pressure_level_val = None
|
| 420 |
+
if pressure_level and pressure_level != 'None':
|
| 421 |
+
try:
|
| 422 |
+
pressure_level_val = float(pressure_level)
|
| 423 |
+
except ValueError:
|
| 424 |
+
pressure_level_val = None
|
| 425 |
+
|
| 426 |
+
time_index_val = request.form.get('time_index')
|
| 427 |
+
# Extract data
|
| 428 |
+
data_values, metadata = processor.extract_data(
|
| 429 |
+
variable,
|
| 430 |
+
time_index = int(time_index_val) if time_index_val and time_index_val != 'None' else 0,
|
| 431 |
+
pressure_level=pressure_level_val
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
# Generate interactive plot (saved as JPG)
|
| 435 |
+
plot_path = interactive_plotter.create_india_map(
|
| 436 |
+
data_values,
|
| 437 |
+
metadata,
|
| 438 |
+
color_theme=color_theme,
|
| 439 |
+
save_plot=True
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
processor.close()
|
| 443 |
+
|
| 444 |
+
if plot_path:
|
| 445 |
+
plot_filename = Path(plot_path).name
|
| 446 |
+
|
| 447 |
+
# Prepare metadata for display
|
| 448 |
+
plot_info = {
|
| 449 |
+
'variable': metadata.get('display_name', 'Unknown Variable'),
|
| 450 |
+
'units': metadata.get('units', ''),
|
| 451 |
+
'shape': str(metadata.get('shape', 'Unknown')),
|
| 452 |
+
'pressure_level': metadata.get('pressure_level'),
|
| 453 |
+
'color_theme': COLOR_THEMES.get(color_theme, color_theme),
|
| 454 |
+
'generated_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
| 455 |
+
'data_range': {
|
| 456 |
+
'min': float(f"{data_values.min():.3f}") if hasattr(data_values, 'min') and not data_values.min() is None else 0,
|
| 457 |
+
'max': float(f"{data_values.max():.3f}") if hasattr(data_values, 'max') and not data_values.max() is None else 0,
|
| 458 |
+
'mean': float(f"{data_values.mean():.3f}") if hasattr(data_values, 'mean') and not data_values.mean() is None else 0
|
| 459 |
+
},
|
| 460 |
+
'is_interactive': True
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
return render_template('plot.html',
|
| 464 |
+
plot_filename=plot_filename,
|
| 465 |
+
plot_info=plot_info)
|
| 466 |
+
else:
|
| 467 |
+
flash('Error generating interactive plot', 'error')
|
| 468 |
+
return redirect(url_for('index'))
|
| 469 |
+
|
| 470 |
+
except Exception as e:
|
| 471 |
+
flash(f'Error creating interactive visualization: {str(e)}', 'error')
|
| 472 |
+
print(f"Full error: {traceback.format_exc()}")
|
| 473 |
+
return redirect(url_for('index'))
|
| 474 |
+
|
| 475 |
+
@app.route('/plot/<filename>')
|
| 476 |
+
def serve_plot(filename):
|
| 477 |
+
"""Serve plot images"""
|
| 478 |
+
try:
|
| 479 |
+
plot_path = Path('plots') / filename
|
| 480 |
+
if plot_path.exists():
|
| 481 |
+
# Determine mimetype based on file extension
|
| 482 |
+
if filename.lower().endswith('.jpg') or filename.lower().endswith('.jpeg'):
|
| 483 |
+
mimetype = 'image/jpeg'
|
| 484 |
+
else:
|
| 485 |
+
mimetype = 'image/png'
|
| 486 |
+
return send_file(str(plot_path), mimetype=mimetype)
|
| 487 |
+
else:
|
| 488 |
+
flash('Plot not found', 'error')
|
| 489 |
+
return redirect(url_for('index'))
|
| 490 |
+
except Exception as e:
|
| 491 |
+
flash(f'Error serving plot: {str(e)}', 'error')
|
| 492 |
+
return redirect(url_for('index'))
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
@app.route('/cleanup')
|
| 496 |
+
def cleanup():
|
| 497 |
+
"""Clean up old files"""
|
| 498 |
+
try:
|
| 499 |
+
# Clean up old plots (older than 24 hours)
|
| 500 |
+
cutoff_time = datetime.now() - timedelta(hours=24)
|
| 501 |
+
cleaned_count = 0
|
| 502 |
+
|
| 503 |
+
for plot_file in Path('plots').glob('*.png'):
|
| 504 |
+
if plot_file.stat().st_mtime < cutoff_time.timestamp():
|
| 505 |
+
plot_file.unlink()
|
| 506 |
+
cleaned_count += 1
|
| 507 |
+
|
| 508 |
+
for plot_file in Path('plots').glob('*.jpg'):
|
| 509 |
+
if plot_file.stat().st_mtime < cutoff_time.timestamp():
|
| 510 |
+
plot_file.unlink()
|
| 511 |
+
cleaned_count += 1
|
| 512 |
+
|
| 513 |
+
flash(f'Cleaned up {cleaned_count} old plot files', 'success')
|
| 514 |
+
return redirect(url_for('index'))
|
| 515 |
+
|
| 516 |
+
except Exception as e:
|
| 517 |
+
print(f"Error during cleanup: {str(e)}")
|
| 518 |
+
flash('Error during cleanup', 'error')
|
| 519 |
+
return redirect(url_for('index'))
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
@app.route('/health')
|
| 523 |
+
def health_check():
|
| 524 |
+
"""Health check endpoint for monitoring"""
|
| 525 |
+
return jsonify({
|
| 526 |
+
'status': 'healthy',
|
| 527 |
+
'timestamp': datetime.now().isoformat(),
|
| 528 |
+
'cds_ready': downloader.is_client_ready()
|
| 529 |
+
})
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
@app.errorhandler(413)
|
| 533 |
+
def too_large(e):
|
| 534 |
+
"""Handle file too large error"""
|
| 535 |
+
flash('File too large. Maximum size is 500MB.', 'error')
|
| 536 |
+
return redirect(url_for('index'))
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
@app.errorhandler(404)
|
| 540 |
+
def not_found(e):
|
| 541 |
+
"""Handle 404 errors"""
|
| 542 |
+
flash('Page not found', 'error')
|
| 543 |
+
return redirect(url_for('index'))
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
@app.errorhandler(500)
|
| 547 |
+
def server_error(e):
|
| 548 |
+
"""Handle server errors"""
|
| 549 |
+
flash('An internal error occurred', 'error')
|
| 550 |
+
return redirect(url_for('index'))
|
| 551 |
+
|
| 552 |
+
|
| 553 |
+
if __name__ == '__main__':
|
| 554 |
+
import os
|
| 555 |
+
|
| 556 |
+
# Get port from environment variable (Hugging Face uses 7860)
|
| 557 |
+
port = int(os.environ.get('PORT', 7860))
|
| 558 |
+
debug_mode = os.environ.get('FLASK_ENV', 'production') != 'production'
|
| 559 |
+
|
| 560 |
+
print("🚀 Starting CAMS Air Pollution Visualization App")
|
| 561 |
+
print(f"📊 Available at: http://localhost:{port}")
|
| 562 |
+
print("🔧 CDS API Ready:", downloader.is_client_ready())
|
| 563 |
+
|
| 564 |
+
# Run the Flask app
|
| 565 |
+
app.run(debug=debug_mode, host='0.0.0.0', port=port)
|
app_readme.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: CAMS Air Pollution Dashboard
|
| 3 |
+
emoji: 🌍
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
app_port: 7860
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# CAMS Air Pollution Dashboard
|
| 13 |
+
|
| 14 |
+
A comprehensive web application for visualizing atmospheric composition data from the Copernicus Atmosphere Monitoring Service (CAMS).
|
| 15 |
+
|
| 16 |
+
## Features
|
| 17 |
+
|
| 18 |
+
- 🌍 Interactive air pollution maps for India
|
| 19 |
+
- 📊 Multiple pollutant visualization (PM2.5, PM10, NO2, O3, CO, etc.)
|
| 20 |
+
- 🎨 Various color themes for data visualization
|
| 21 |
+
- 📁 Support for NetCDF file uploads
|
| 22 |
+
- 🌐 Direct CAMS data download integration
|
| 23 |
+
- 📈 Statistical analysis and hover information
|
| 24 |
+
|
| 25 |
+
## Usage
|
| 26 |
+
|
| 27 |
+
1. Upload your own NetCDF files or download CAMS data for specific dates
|
| 28 |
+
2. Select variables, pressure levels, and time points
|
| 29 |
+
3. Generate static or interactive pollution maps
|
| 30 |
+
4. Explore air quality data with detailed statistics
|
| 31 |
+
|
| 32 |
+
## Data Source
|
| 33 |
+
|
| 34 |
+
This application uses data from the Copernicus Atmosphere Monitoring Service (CAMS), which provides global atmospheric composition forecasts and analyses.
|
| 35 |
+
|
| 36 |
+
## Note
|
| 37 |
+
|
| 38 |
+
For downloading CAMS data, you'll need to set up CDS API credentials. You can upload your own NetCDF files to explore the visualization features without API access.
|
cams_downloader.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# cams_downloader.py
|
| 2 |
+
# Download CAMS atmospheric composition data
|
| 3 |
+
|
| 4 |
+
import cdsapi
|
| 5 |
+
import zipfile
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
class CAMSDownloader:
|
| 12 |
+
def __init__(self, download_dir="downloads"):
|
| 13 |
+
"""
|
| 14 |
+
Initialize CAMS downloader
|
| 15 |
+
|
| 16 |
+
Parameters:
|
| 17 |
+
download_dir (str): Directory to store downloaded files
|
| 18 |
+
"""
|
| 19 |
+
self.download_dir = Path(download_dir)
|
| 20 |
+
self.download_dir.mkdir(exist_ok=True)
|
| 21 |
+
|
| 22 |
+
# Create subdirectories
|
| 23 |
+
self.extracted_dir = self.download_dir / "extracted"
|
| 24 |
+
self.extracted_dir.mkdir(exist_ok=True)
|
| 25 |
+
|
| 26 |
+
self.client = None
|
| 27 |
+
self._init_client()
|
| 28 |
+
|
| 29 |
+
def _init_client(self):
|
| 30 |
+
"""Initialize CDS API client"""
|
| 31 |
+
try:
|
| 32 |
+
# Try to read .cdsapirc file from current directory first, then home directory
|
| 33 |
+
cdsapirc_path = Path.cwd() / ".cdsapirc"
|
| 34 |
+
if not cdsapirc_path.exists():
|
| 35 |
+
cdsapirc_path = Path.home() / ".cdsapirc"
|
| 36 |
+
|
| 37 |
+
if cdsapirc_path.exists():
|
| 38 |
+
# Parse credentials from .cdsapirc
|
| 39 |
+
with open(cdsapirc_path, 'r') as f:
|
| 40 |
+
lines = f.readlines()
|
| 41 |
+
|
| 42 |
+
url = None
|
| 43 |
+
key = None
|
| 44 |
+
for line in lines:
|
| 45 |
+
line = line.strip()
|
| 46 |
+
if line.startswith('url:'):
|
| 47 |
+
url = line.split(':', 1)[1].strip()
|
| 48 |
+
elif line.startswith('key:'):
|
| 49 |
+
key = line.split(':', 1)[1].strip()
|
| 50 |
+
|
| 51 |
+
print(url, key)
|
| 52 |
+
if url and key:
|
| 53 |
+
self.client = cdsapi.Client(key=key, url=url)
|
| 54 |
+
print("✅ CDS API client initialized from .cdsapirc")
|
| 55 |
+
else:
|
| 56 |
+
raise ValueError("Could not parse URL or key from .cdsapirc file")
|
| 57 |
+
else:
|
| 58 |
+
# Try default initialization (will look for environment variables)
|
| 59 |
+
self.client = cdsapi.Client()
|
| 60 |
+
print("✅ CDS API client initialized with default settings")
|
| 61 |
+
|
| 62 |
+
except Exception as e:
|
| 63 |
+
print(f"⚠️ Warning: Could not initialize CDS API client: {str(e)}")
|
| 64 |
+
print("Please ensure you have:")
|
| 65 |
+
print("1. Created an account at https://cds.climate.copernicus.eu/")
|
| 66 |
+
print("2. Created a .cdsapirc file in your home directory with your credentials")
|
| 67 |
+
print("3. Or set CDSAPI_URL and CDSAPI_KEY environment variables")
|
| 68 |
+
self.client = None
|
| 69 |
+
|
| 70 |
+
def is_client_ready(self):
|
| 71 |
+
"""Check if CDS API client is ready"""
|
| 72 |
+
return self.client is not None
|
| 73 |
+
|
| 74 |
+
def download_cams_data(self, date_str, variables=None, pressure_levels=None):
|
| 75 |
+
"""
|
| 76 |
+
Download CAMS atmospheric composition data for a specific date
|
| 77 |
+
|
| 78 |
+
Parameters:
|
| 79 |
+
date_str (str): Date in YYYY-MM-DD format
|
| 80 |
+
variables (list): List of variables to download (default: common air pollution variables)
|
| 81 |
+
pressure_levels (list): List of pressure levels (default: standard levels)
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
str: Path to downloaded ZIP file
|
| 85 |
+
"""
|
| 86 |
+
if not self.is_client_ready():
|
| 87 |
+
raise Exception("CDS API client not initialized. Please check your credentials.")
|
| 88 |
+
|
| 89 |
+
# Validate date
|
| 90 |
+
try:
|
| 91 |
+
target_date = pd.to_datetime(date_str)
|
| 92 |
+
date_str = target_date.strftime('%Y-%m-%d')
|
| 93 |
+
except:
|
| 94 |
+
raise ValueError(f"Invalid date format: {date_str}. Use YYYY-MM-DD format.")
|
| 95 |
+
|
| 96 |
+
# Check if data already exists
|
| 97 |
+
filename = f"{date_str}-cams.nc.zip"
|
| 98 |
+
filepath = self.download_dir / filename
|
| 99 |
+
|
| 100 |
+
if filepath.exists():
|
| 101 |
+
print(f"✅ Data for {date_str} already exists: {filename}")
|
| 102 |
+
return str(filepath)
|
| 103 |
+
|
| 104 |
+
# Default variables (common air pollution variables)
|
| 105 |
+
if variables is None:
|
| 106 |
+
variables = [
|
| 107 |
+
# Meteorological surface-level variables
|
| 108 |
+
"10m_u_component_of_wind",
|
| 109 |
+
"10m_v_component_of_wind",
|
| 110 |
+
"2m_temperature",
|
| 111 |
+
"mean_sea_level_pressure",
|
| 112 |
+
|
| 113 |
+
# Pollution surface-level variables
|
| 114 |
+
"particulate_matter_1um",
|
| 115 |
+
"particulate_matter_2.5um",
|
| 116 |
+
"particulate_matter_10um",
|
| 117 |
+
"total_column_carbon_monoxide",
|
| 118 |
+
"total_column_nitrogen_monoxide",
|
| 119 |
+
"total_column_nitrogen_dioxide",
|
| 120 |
+
"total_column_ozone",
|
| 121 |
+
"total_column_sulphur_dioxide",
|
| 122 |
+
|
| 123 |
+
# Meteorological atmospheric variables
|
| 124 |
+
"u_component_of_wind",
|
| 125 |
+
"v_component_of_wind",
|
| 126 |
+
"temperature",
|
| 127 |
+
"geopotential",
|
| 128 |
+
"specific_humidity",
|
| 129 |
+
|
| 130 |
+
# Pollution atmospheric variables
|
| 131 |
+
"carbon_monoxide",
|
| 132 |
+
"nitrogen_dioxide",
|
| 133 |
+
"nitrogen_monoxide",
|
| 134 |
+
"ozone",
|
| 135 |
+
"sulphur_dioxide",
|
| 136 |
+
]
|
| 137 |
+
|
| 138 |
+
# Default pressure levels
|
| 139 |
+
if pressure_levels is None:
|
| 140 |
+
pressure_levels = [
|
| 141 |
+
"50", "100", "150", "200", "250", "300", "400",
|
| 142 |
+
"500", "600", "700", "850", "925", "1000",
|
| 143 |
+
]
|
| 144 |
+
|
| 145 |
+
print(f"🔄 Downloading CAMS data for {date_str}...")
|
| 146 |
+
print(f"Variables: {len(variables)} selected")
|
| 147 |
+
print(f"Pressure levels: {len(pressure_levels)} levels")
|
| 148 |
+
|
| 149 |
+
try:
|
| 150 |
+
# Make the API request
|
| 151 |
+
self.client.retrieve(
|
| 152 |
+
"cams-global-atmospheric-composition-forecasts",
|
| 153 |
+
{
|
| 154 |
+
"type": "forecast",
|
| 155 |
+
"leadtime_hour": "0",
|
| 156 |
+
"variable": variables,
|
| 157 |
+
"pressure_level": pressure_levels,
|
| 158 |
+
"date": date_str,
|
| 159 |
+
"time": ["00:00", "12:00"], # Two time steps
|
| 160 |
+
"format": "netcdf_zip",
|
| 161 |
+
},
|
| 162 |
+
str(filepath),
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
print(f"✅ Successfully downloaded: {filename}")
|
| 166 |
+
return str(filepath)
|
| 167 |
+
|
| 168 |
+
except Exception as e:
|
| 169 |
+
# Clean up partial download
|
| 170 |
+
if filepath.exists():
|
| 171 |
+
filepath.unlink()
|
| 172 |
+
raise Exception(f"Error downloading CAMS data: {str(e)}")
|
| 173 |
+
|
| 174 |
+
def extract_cams_files(self, zip_path):
|
| 175 |
+
"""
|
| 176 |
+
Extract surface and atmospheric data from CAMS ZIP file
|
| 177 |
+
|
| 178 |
+
Parameters:
|
| 179 |
+
zip_path (str): Path to CAMS ZIP file
|
| 180 |
+
|
| 181 |
+
Returns:
|
| 182 |
+
dict: Paths to extracted files
|
| 183 |
+
"""
|
| 184 |
+
zip_path = Path(zip_path)
|
| 185 |
+
if not zip_path.exists():
|
| 186 |
+
raise FileNotFoundError(f"ZIP file not found: {zip_path}")
|
| 187 |
+
|
| 188 |
+
# Extract date from filename
|
| 189 |
+
date_str = zip_path.stem.replace("-cams.nc", "")
|
| 190 |
+
|
| 191 |
+
surface_path = self.extracted_dir / f"{date_str}-cams-surface.nc"
|
| 192 |
+
atmospheric_path = self.extracted_dir / f"{date_str}-cams-atmospheric.nc"
|
| 193 |
+
|
| 194 |
+
extracted_files = {}
|
| 195 |
+
|
| 196 |
+
try:
|
| 197 |
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
| 198 |
+
zip_contents = zf.namelist()
|
| 199 |
+
|
| 200 |
+
# Extract surface data
|
| 201 |
+
surface_file = None
|
| 202 |
+
for file in zip_contents:
|
| 203 |
+
if 'sfc' in file.lower() or file.endswith('_sfc.nc'):
|
| 204 |
+
surface_file = file
|
| 205 |
+
break
|
| 206 |
+
|
| 207 |
+
if surface_file and not surface_path.exists():
|
| 208 |
+
with open(surface_path, "wb") as f:
|
| 209 |
+
f.write(zf.read(surface_file))
|
| 210 |
+
print(f"✅ Extracted surface data: {surface_path.name}")
|
| 211 |
+
extracted_files['surface'] = str(surface_path)
|
| 212 |
+
elif surface_path.exists():
|
| 213 |
+
extracted_files['surface'] = str(surface_path)
|
| 214 |
+
|
| 215 |
+
# Extract atmospheric data
|
| 216 |
+
atmospheric_file = None
|
| 217 |
+
for file in zip_contents:
|
| 218 |
+
if 'plev' in file.lower() or file.endswith('_plev.nc'):
|
| 219 |
+
atmospheric_file = file
|
| 220 |
+
break
|
| 221 |
+
|
| 222 |
+
if atmospheric_file and not atmospheric_path.exists():
|
| 223 |
+
with open(atmospheric_path, "wb") as f:
|
| 224 |
+
f.write(zf.read(atmospheric_file))
|
| 225 |
+
print(f"✅ Extracted atmospheric data: {atmospheric_path.name}")
|
| 226 |
+
extracted_files['atmospheric'] = str(atmospheric_path)
|
| 227 |
+
elif atmospheric_path.exists():
|
| 228 |
+
extracted_files['atmospheric'] = str(atmospheric_path)
|
| 229 |
+
|
| 230 |
+
# If no specific files found, extract all .nc files
|
| 231 |
+
if not extracted_files:
|
| 232 |
+
nc_files = [f for f in zip_contents if f.endswith('.nc')]
|
| 233 |
+
for nc_file in nc_files:
|
| 234 |
+
output_path = self.extracted_dir / nc_file
|
| 235 |
+
if not output_path.exists():
|
| 236 |
+
with open(output_path, "wb") as f:
|
| 237 |
+
f.write(zf.read(nc_file))
|
| 238 |
+
extracted_files[nc_file] = str(output_path)
|
| 239 |
+
|
| 240 |
+
except Exception as e:
|
| 241 |
+
raise Exception(f"Error extracting ZIP file: {str(e)}")
|
| 242 |
+
|
| 243 |
+
if not extracted_files:
|
| 244 |
+
raise Exception("No NetCDF files found in ZIP archive")
|
| 245 |
+
|
| 246 |
+
return extracted_files
|
| 247 |
+
|
| 248 |
+
def get_available_dates(self, start_date=None, end_date=None):
|
| 249 |
+
"""
|
| 250 |
+
Get list of dates for which CAMS data is typically available
|
| 251 |
+
Note: This doesn't check actual availability, just generates reasonable date range
|
| 252 |
+
|
| 253 |
+
Parameters:
|
| 254 |
+
start_date (str): Start date (default: 30 days ago)
|
| 255 |
+
end_date (str): End date (default: yesterday)
|
| 256 |
+
|
| 257 |
+
Returns:
|
| 258 |
+
list: List of date strings in YYYY-MM-DD format
|
| 259 |
+
"""
|
| 260 |
+
if start_date is None:
|
| 261 |
+
start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
|
| 262 |
+
|
| 263 |
+
if end_date is None:
|
| 264 |
+
end_date = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
|
| 265 |
+
|
| 266 |
+
# Generate date range
|
| 267 |
+
date_range = pd.date_range(start=start_date, end=end_date, freq='D')
|
| 268 |
+
return [date.strftime('%Y-%m-%d') for date in date_range]
|
| 269 |
+
|
| 270 |
+
def list_downloaded_files(self):
|
| 271 |
+
"""List all downloaded CAMS files"""
|
| 272 |
+
downloaded_files = []
|
| 273 |
+
|
| 274 |
+
for zip_file in self.download_dir.glob("*-cams.nc.zip"):
|
| 275 |
+
date_str = zip_file.stem.replace("-cams.nc", "")
|
| 276 |
+
file_info = {
|
| 277 |
+
'date': date_str,
|
| 278 |
+
'zip_path': str(zip_file),
|
| 279 |
+
'size_mb': zip_file.stat().st_size / (1024 * 1024),
|
| 280 |
+
'downloaded': zip_file.stat().st_mtime
|
| 281 |
+
}
|
| 282 |
+
downloaded_files.append(file_info)
|
| 283 |
+
|
| 284 |
+
# Sort by date (newest first)
|
| 285 |
+
downloaded_files.sort(key=lambda x: x['date'], reverse=True)
|
| 286 |
+
return downloaded_files
|
| 287 |
+
|
| 288 |
+
def cleanup_old_files(self, days_old=30):
|
| 289 |
+
"""
|
| 290 |
+
Clean up downloaded files older than specified days
|
| 291 |
+
|
| 292 |
+
Parameters:
|
| 293 |
+
days_old (int): Delete files older than this many days
|
| 294 |
+
"""
|
| 295 |
+
try:
|
| 296 |
+
cutoff_date = datetime.now() - timedelta(days=days_old)
|
| 297 |
+
|
| 298 |
+
deleted_count = 0
|
| 299 |
+
for zip_file in self.download_dir.glob("*-cams.nc.zip"):
|
| 300 |
+
if datetime.fromtimestamp(zip_file.stat().st_mtime) < cutoff_date:
|
| 301 |
+
zip_file.unlink()
|
| 302 |
+
deleted_count += 1
|
| 303 |
+
|
| 304 |
+
# Also clean extracted files
|
| 305 |
+
for nc_file in self.extracted_dir.glob("*.nc"):
|
| 306 |
+
if datetime.fromtimestamp(nc_file.stat().st_mtime) < cutoff_date:
|
| 307 |
+
nc_file.unlink()
|
| 308 |
+
deleted_count += 1
|
| 309 |
+
|
| 310 |
+
print(f"🧹 Cleaned up {deleted_count} old files")
|
| 311 |
+
return deleted_count
|
| 312 |
+
|
| 313 |
+
except Exception as e:
|
| 314 |
+
print(f"Error during cleanup: {str(e)}")
|
| 315 |
+
return 0
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def test_cams_downloader():
|
| 319 |
+
"""Test function for CAMS downloader"""
|
| 320 |
+
print("Testing CAMS downloader...")
|
| 321 |
+
|
| 322 |
+
downloader = CAMSDownloader()
|
| 323 |
+
|
| 324 |
+
if not downloader.is_client_ready():
|
| 325 |
+
print("❌ CDS API client not ready. Please check your credentials.")
|
| 326 |
+
return False
|
| 327 |
+
|
| 328 |
+
# Test with recent date
|
| 329 |
+
test_date = (datetime.now() - timedelta(days=600)).strftime('%Y-%m-%d')
|
| 330 |
+
|
| 331 |
+
print(f"Testing download for date: {test_date}")
|
| 332 |
+
print("⚠️ This may take several minutes for the first download...")
|
| 333 |
+
|
| 334 |
+
try:
|
| 335 |
+
# Download data (will skip if already exists)
|
| 336 |
+
zip_path = downloader.download_cams_data(test_date)
|
| 337 |
+
print(f"✅ Download successful: {zip_path}")
|
| 338 |
+
|
| 339 |
+
# Test extraction
|
| 340 |
+
extracted_files = downloader.extract_cams_files(zip_path)
|
| 341 |
+
print(f"✅ Extraction successful: {len(extracted_files)} files")
|
| 342 |
+
|
| 343 |
+
# List downloaded files
|
| 344 |
+
downloaded = downloader.list_downloaded_files()
|
| 345 |
+
print(f"✅ Found {len(downloaded)} downloaded files")
|
| 346 |
+
|
| 347 |
+
return True
|
| 348 |
+
|
| 349 |
+
except Exception as e:
|
| 350 |
+
print(f"❌ Test failed: {str(e)}")
|
| 351 |
+
return False
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
if __name__ == "__main__":
|
| 355 |
+
test_cams_downloader()
|
constants.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Air pollution variables and their properties
|
| 2 |
+
|
| 3 |
+
AIR_POLLUTION_VARIABLES = {
|
| 4 |
+
# PM2.5
|
| 5 |
+
'pm2p5': {'units': 'µg/m³', 'name': 'PM2.5', 'cmap': 'YlOrRd', 'vmax_percentile': 95, 'type': 'surface'},
|
| 6 |
+
'pm25': {'units': 'µg/m³', 'name': 'PM2.5', 'cmap': 'YlOrRd', 'vmax_percentile': 95, 'type': 'surface'},
|
| 7 |
+
'PM2P5': {'units': 'µg/m³', 'name': 'PM2.5', 'cmap': 'YlOrRd', 'vmax_percentile': 95, 'type': 'surface'},
|
| 8 |
+
'PM25': {'units': 'µg/m³', 'name': 'PM2.5', 'cmap': 'YlOrRd', 'vmax_percentile': 95, 'type': 'surface'},
|
| 9 |
+
'pm2_5': {'units': 'µg/m³', 'name': 'PM2.5', 'cmap': 'YlOrRd', 'vmax_percentile': 95, 'type': 'surface'},
|
| 10 |
+
'PM2_5': {'units': 'µg/m³', 'name': 'PM2.5', 'cmap': 'YlOrRd', 'vmax_percentile': 95, 'type': 'surface'},
|
| 11 |
+
'particulate_matter_2_5': {'units': 'µg/m³', 'name': 'PM2.5', 'cmap': 'YlOrRd', 'vmax_percentile': 95, 'type': 'surface'},
|
| 12 |
+
'particulate_matter_2.5um': {'units': 'µg/m³', 'name': 'PM2.5', 'cmap': 'YlOrRd', 'vmax_percentile': 95, 'type': 'surface'},
|
| 13 |
+
'mass_concentration_of_pm2p5_ambient_aerosol_particles_in_air': {'units': 'µg/m³', 'name': 'PM2.5', 'cmap': 'YlOrRd', 'vmax_percentile': 95, 'type': 'surface'},
|
| 14 |
+
|
| 15 |
+
# PM10
|
| 16 |
+
'pm10': {'units': 'µg/m³', 'name': 'PM10', 'cmap': 'Oranges', 'vmax_percentile': 95, 'type': 'surface'},
|
| 17 |
+
'PM10': {'units': 'µg/m³', 'name': 'PM10', 'cmap': 'Oranges', 'vmax_percentile': 95, 'type': 'surface'},
|
| 18 |
+
'pm10p0': {'units': 'µg/m³', 'name': 'PM10', 'cmap': 'Oranges', 'vmax_percentile': 95, 'type': 'surface'},
|
| 19 |
+
'particulate_matter_10': {'units': 'µg/m³', 'name': 'PM10', 'cmap': 'Oranges', 'vmax_percentile': 95, 'type': 'surface'},
|
| 20 |
+
'particulate_matter_10um': {'units': 'µg/m³', 'name': 'PM10', 'cmap': 'Oranges', 'vmax_percentile': 95, 'type': 'surface'},
|
| 21 |
+
'mass_concentration_of_pm10_ambient_aerosol_particles_in_air': {'units': 'µg/m³', 'name': 'PM10', 'cmap': 'Oranges', 'vmax_percentile': 95, 'type': 'surface'},
|
| 22 |
+
|
| 23 |
+
# PM1
|
| 24 |
+
'pm1': {'units': 'µg/m³', 'name': 'PM1', 'cmap': 'Reds', 'vmax_percentile': 95, 'type': 'surface'},
|
| 25 |
+
'PM1': {'units': 'µg/m³', 'name': 'PM1', 'cmap': 'Reds', 'vmax_percentile': 95, 'type': 'surface'},
|
| 26 |
+
'particulate_matter_1um': {'units': 'µg/m³', 'name': 'PM1', 'cmap': 'Reds', 'vmax_percentile': 95, 'type': 'surface'},
|
| 27 |
+
|
| 28 |
+
# NO2
|
| 29 |
+
'no2': {'units': 'µg/m³', 'name': 'NO₂', 'cmap': 'Reds', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 30 |
+
'NO2': {'units': 'µg/m³', 'name': 'NO₂', 'cmap': 'Reds', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 31 |
+
'nitrogen_dioxide': {'units': 'µg/m³', 'name': 'NO₂', 'cmap': 'Reds', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 32 |
+
'mass_concentration_of_nitrogen_dioxide_in_air': {'units': 'µg/m³', 'name': 'NO₂', 'cmap': 'Reds', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 33 |
+
|
| 34 |
+
# SO2
|
| 35 |
+
'so2': {'units': 'µg/m³', 'name': 'SO₂', 'cmap': 'Purples', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 36 |
+
'SO2': {'units': 'µg/m³', 'name': 'SO₂', 'cmap': 'Purples', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 37 |
+
'sulphur_dioxide': {'units': 'µg/m³', 'name': 'SO₂', 'cmap': 'Purples', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 38 |
+
'sulfur_dioxide': {'units': 'µg/m³', 'name': 'SO₂', 'cmap': 'Purples', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 39 |
+
'mass_concentration_of_sulfur_dioxide_in_air': {'units': 'µg/m³', 'name': 'SO₂', 'cmap': 'Purples', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 40 |
+
|
| 41 |
+
# O3
|
| 42 |
+
'o3': {'units': 'µg/m³', 'name': 'O₃', 'cmap': 'Blues', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 43 |
+
'O3': {'units': 'µg/m³', 'name': 'O₃', 'cmap': 'Blues', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 44 |
+
'ozone': {'units': 'µg/m³', 'name': 'O₃', 'cmap': 'Blues', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 45 |
+
'mass_concentration_of_ozone_in_air': {'units': 'µg/m³', 'name': 'O₃', 'cmap': 'Blues', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 46 |
+
|
| 47 |
+
# CO
|
| 48 |
+
'co': {'units': 'mg/m³', 'name': 'CO', 'cmap': 'Greens', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 49 |
+
'CO': {'units': 'mg/m³', 'name': 'CO', 'cmap': 'Greens', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 50 |
+
'carbon_monoxide': {'units': 'mg/m³', 'name': 'CO', 'cmap': 'Greens', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 51 |
+
'mass_concentration_of_carbon_monoxide_in_air': {'units': 'mg/m³', 'name': 'CO', 'cmap': 'Greens', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 52 |
+
|
| 53 |
+
# NO (Nitrogen Monoxide)
|
| 54 |
+
'no': {'units': 'µg/m³', 'name': 'NO', 'cmap': 'Oranges', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 55 |
+
'NO': {'units': 'µg/m³', 'name': 'NO', 'cmap': 'Oranges', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 56 |
+
'nitrogen_monoxide': {'units': 'µg/m³', 'name': 'NO', 'cmap': 'Oranges', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 57 |
+
|
| 58 |
+
# NH3
|
| 59 |
+
'nh3': {'units': 'µg/m³', 'name': 'NH₃', 'cmap': 'viridis', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 60 |
+
'NH3': {'units': 'µg/m³', 'name': 'NH₃', 'cmap': 'viridis', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 61 |
+
'ammonia': {'units': 'µg/m³', 'name': 'NH₃', 'cmap': 'viridis', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 62 |
+
'mass_concentration_of_ammonia_in_air': {'units': 'µg/m³', 'name': 'NH₃', 'cmap': 'viridis', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 63 |
+
|
| 64 |
+
# Total Column variables (these are surface-level total column measurements)
|
| 65 |
+
'total_column_carbon_monoxide': {'units': 'mol/m²', 'name': 'Total Column CO', 'cmap': 'Greens', 'vmax_percentile': 90, 'type': 'surface'},
|
| 66 |
+
'total_column_nitrogen_monoxide': {'units': 'mol/m²', 'name': 'Total Column NO', 'cmap': 'Oranges', 'vmax_percentile': 90, 'type': 'surface'},
|
| 67 |
+
'total_column_nitrogen_dioxide': {'units': 'mol/m²', 'name': 'Total Column NO₂', 'cmap': 'Reds', 'vmax_percentile': 90, 'type': 'surface'},
|
| 68 |
+
'total_column_ozone': {'units': 'mol/m²', 'name': 'Total Column O₃', 'cmap': 'Blues', 'vmax_percentile': 90, 'type': 'surface'},
|
| 69 |
+
'total_column_sulphur_dioxide': {'units': 'mol/m²', 'name': 'Total Column SO₂', 'cmap': 'Purples', 'vmax_percentile': 90, 'type': 'surface'},
|
| 70 |
+
|
| 71 |
+
# Legacy total column names
|
| 72 |
+
'tcno2': {'units': 'mol/m²', 'name': 'Total Column NO₂', 'cmap': 'Reds', 'vmax_percentile': 90, 'type': 'surface'},
|
| 73 |
+
'tcso2': {'units': 'mol/m²', 'name': 'Total Column SO₂', 'cmap': 'Purples', 'vmax_percentile': 90, 'type': 'surface'},
|
| 74 |
+
'tco3': {'units': 'mol/m²', 'name': 'Total Column O₃', 'cmap': 'Blues', 'vmax_percentile': 90, 'type': 'surface'},
|
| 75 |
+
'tcco': {'units': 'mol/m²', 'name': 'Total Column CO', 'cmap': 'Greens', 'vmax_percentile': 90, 'type': 'surface'},
|
| 76 |
+
|
| 77 |
+
# AOD (Aerosol Optical Depth) - surface measurement
|
| 78 |
+
'aod550': {'units': '', 'name': 'AOD 550nm', 'cmap': 'plasma', 'vmax_percentile': 95, 'type': 'surface'},
|
| 79 |
+
'aod': {'units': '', 'name': 'AOD', 'cmap': 'plasma', 'vmax_percentile': 95, 'type': 'surface'},
|
| 80 |
+
'aerosol_optical_depth': {'units': '', 'name': 'AOD', 'cmap': 'plasma', 'vmax_percentile': 95, 'type': 'surface'},
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
# Available color themes for plotting
|
| 84 |
+
COLOR_THEMES = {
|
| 85 |
+
'YlOrRd': 'Yellow-Orange-Red',
|
| 86 |
+
'Oranges': 'Oranges',
|
| 87 |
+
'Reds': 'Reds',
|
| 88 |
+
'Purples': 'Purples',
|
| 89 |
+
'Blues': 'Blues',
|
| 90 |
+
'Greens': 'Greens',
|
| 91 |
+
'viridis': 'Viridis',
|
| 92 |
+
'plasma': 'Plasma',
|
| 93 |
+
'inferno': 'Inferno',
|
| 94 |
+
'magma': 'Magma',
|
| 95 |
+
'cividis': 'Cividis',
|
| 96 |
+
'coolwarm': 'Cool-Warm',
|
| 97 |
+
'RdYlBu': 'Red-Yellow-Blue',
|
| 98 |
+
'Spectral': 'Spectral'
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
# India map boundaries
|
| 102 |
+
INDIA_BOUNDS = {
|
| 103 |
+
'lat_min': 6.0,
|
| 104 |
+
'lat_max': 38.0,
|
| 105 |
+
'lon_min': 68.0,
|
| 106 |
+
'lon_max': 98.0
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
# Common pressure levels for atmospheric variables (in hPa)
|
| 110 |
+
PRESSURE_LEVELS = [50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000]
|
| 111 |
+
|
| 112 |
+
# File upload settings
|
| 113 |
+
ALLOWED_EXTENSIONS = {'nc', 'zip'}
|
| 114 |
+
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500MB
|
data_processor.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NetCDF file processing and air pollution variable detection
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import zipfile
|
| 5 |
+
import warnings
|
| 6 |
+
import tempfile
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
import xarray as xr
|
| 11 |
+
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
|
| 15 |
+
# Imports from our Modules
|
| 16 |
+
from constants import AIR_POLLUTION_VARIABLES, PRESSURE_LEVELS
|
| 17 |
+
warnings.filterwarnings('ignore')
|
| 18 |
+
|
| 19 |
+
class NetCDFProcessor:
|
| 20 |
+
def __init__(self, file_path):
|
| 21 |
+
"""
|
| 22 |
+
Initialize NetCDF processor
|
| 23 |
+
|
| 24 |
+
Parameters:
|
| 25 |
+
file_path (str): Path to NetCDF or ZIP file
|
| 26 |
+
"""
|
| 27 |
+
self.file_path = Path(file_path)
|
| 28 |
+
self.dataset = None
|
| 29 |
+
self.surface_dataset = None
|
| 30 |
+
self.atmospheric_dataset = None
|
| 31 |
+
self.detected_variables = {}
|
| 32 |
+
|
| 33 |
+
def load_dataset(self):
|
| 34 |
+
"""Load NetCDF dataset from file or ZIP"""
|
| 35 |
+
try:
|
| 36 |
+
if self.file_path.suffix.lower() == '.zip':
|
| 37 |
+
return self._load_from_zip()
|
| 38 |
+
elif self.file_path.suffix.lower() == '.nc':
|
| 39 |
+
return self._load_from_netcdf()
|
| 40 |
+
else:
|
| 41 |
+
raise ValueError("Unsupported file format. Use .nc or .zip files.")
|
| 42 |
+
|
| 43 |
+
except Exception as e:
|
| 44 |
+
raise Exception(f"Error loading dataset: {str(e)}")
|
| 45 |
+
|
| 46 |
+
def _load_from_zip(self):
|
| 47 |
+
"""Load dataset from ZIP file (CAMS format)"""
|
| 48 |
+
with zipfile.ZipFile(self.file_path, 'r') as zf:
|
| 49 |
+
zip_contents = zf.namelist()
|
| 50 |
+
|
| 51 |
+
# Look for surface and atmospheric data files
|
| 52 |
+
surface_file = None
|
| 53 |
+
atmospheric_file = None
|
| 54 |
+
|
| 55 |
+
for file in zip_contents:
|
| 56 |
+
if 'sfc' in file.lower() or 'surface' in file.lower():
|
| 57 |
+
surface_file = file
|
| 58 |
+
elif 'plev' in file.lower() or 'pressure' in file.lower() or 'atmospheric' in file.lower():
|
| 59 |
+
atmospheric_file = file
|
| 60 |
+
|
| 61 |
+
# Load surface data if available
|
| 62 |
+
if surface_file:
|
| 63 |
+
with zf.open(surface_file) as f:
|
| 64 |
+
with tempfile.NamedTemporaryFile(suffix='.nc') as tmp:
|
| 65 |
+
tmp.write(f.read())
|
| 66 |
+
tmp.flush()
|
| 67 |
+
self.surface_dataset = xr.open_dataset(tmp.name, engine='netcdf4')
|
| 68 |
+
print(f"Loaded surface data: {surface_file}")
|
| 69 |
+
|
| 70 |
+
# Load atmospheric data if available
|
| 71 |
+
if atmospheric_file:
|
| 72 |
+
with zf.open(atmospheric_file) as f:
|
| 73 |
+
with tempfile.NamedTemporaryFile(suffix='.nc') as tmp:
|
| 74 |
+
tmp.write(f.read())
|
| 75 |
+
tmp.flush()
|
| 76 |
+
self.atmospheric_dataset = xr.open_dataset(tmp.name, engine='netcdf4')
|
| 77 |
+
print(f"Loaded atmospheric data: {atmospheric_file}")
|
| 78 |
+
|
| 79 |
+
# If no specific files found, try to load the first .nc file
|
| 80 |
+
if not surface_file and not atmospheric_file:
|
| 81 |
+
nc_files = [f for f in zip_contents if f.endswith('.nc')]
|
| 82 |
+
if nc_files:
|
| 83 |
+
with zf.open(nc_files[0]) as f:
|
| 84 |
+
with tempfile.NamedTemporaryFile(suffix='.nc') as tmp:
|
| 85 |
+
tmp.write(f.read())
|
| 86 |
+
tmp.flush()
|
| 87 |
+
self.dataset = xr.open_dataset(tmp.name, engine='netcdf4')
|
| 88 |
+
print(f"Loaded dataset: {nc_files[0]}")
|
| 89 |
+
else:
|
| 90 |
+
raise ValueError("No NetCDF files found in ZIP")
|
| 91 |
+
|
| 92 |
+
return True
|
| 93 |
+
|
| 94 |
+
def _load_from_netcdf(self):
|
| 95 |
+
"""Load dataset from single NetCDF file"""
|
| 96 |
+
self.dataset = xr.open_dataset(self.file_path)
|
| 97 |
+
print(f"Loaded NetCDF file: {self.file_path.name}")
|
| 98 |
+
return True
|
| 99 |
+
|
| 100 |
+
def detect_variables(self):
|
| 101 |
+
"""Detect air pollution variables in all loaded datasets"""
|
| 102 |
+
self.detected_variables = {}
|
| 103 |
+
|
| 104 |
+
# Check surface dataset
|
| 105 |
+
if self.surface_dataset is not None:
|
| 106 |
+
surface_vars = self._detect_variables_in_dataset(self.surface_dataset, 'surface')
|
| 107 |
+
self.detected_variables.update(surface_vars)
|
| 108 |
+
|
| 109 |
+
# Check atmospheric dataset
|
| 110 |
+
if self.atmospheric_dataset is not None:
|
| 111 |
+
atmo_vars = self._detect_variables_in_dataset(self.atmospheric_dataset, 'atmospheric')
|
| 112 |
+
self.detected_variables.update(atmo_vars)
|
| 113 |
+
|
| 114 |
+
# Check main dataset if no separate files
|
| 115 |
+
if self.dataset is not None:
|
| 116 |
+
main_vars = self._detect_variables_in_dataset(self.dataset, 'unknown')
|
| 117 |
+
self.detected_variables.update(main_vars)
|
| 118 |
+
|
| 119 |
+
return self.detected_variables
|
| 120 |
+
|
| 121 |
+
def _detect_variables_in_dataset(self, dataset, dataset_type):
|
| 122 |
+
"""Detect air pollution variables in a specific dataset"""
|
| 123 |
+
detected = {}
|
| 124 |
+
|
| 125 |
+
for var_name in dataset.data_vars:
|
| 126 |
+
var_name_lower = var_name.lower()
|
| 127 |
+
|
| 128 |
+
# Check exact matches first
|
| 129 |
+
if var_name in AIR_POLLUTION_VARIABLES:
|
| 130 |
+
detected[var_name] = AIR_POLLUTION_VARIABLES[var_name].copy()
|
| 131 |
+
detected[var_name]['original_name'] = var_name
|
| 132 |
+
detected[var_name]['dataset_type'] = dataset_type
|
| 133 |
+
detected[var_name]['shape'] = dataset[var_name].shape
|
| 134 |
+
detected[var_name]['dims'] = list(dataset[var_name].dims)
|
| 135 |
+
|
| 136 |
+
elif var_name_lower in AIR_POLLUTION_VARIABLES:
|
| 137 |
+
detected[var_name] = AIR_POLLUTION_VARIABLES[var_name_lower].copy()
|
| 138 |
+
detected[var_name]['original_name'] = var_name
|
| 139 |
+
detected[var_name]['dataset_type'] = dataset_type
|
| 140 |
+
detected[var_name]['shape'] = dataset[var_name].shape
|
| 141 |
+
detected[var_name]['dims'] = list(dataset[var_name].dims)
|
| 142 |
+
|
| 143 |
+
else:
|
| 144 |
+
# Check for partial matches
|
| 145 |
+
var_info = dataset[var_name]
|
| 146 |
+
long_name = getattr(var_info, 'long_name', '').lower()
|
| 147 |
+
standard_name = getattr(var_info, 'standard_name', '').lower()
|
| 148 |
+
|
| 149 |
+
# Check for keywords
|
| 150 |
+
pollution_keywords = {
|
| 151 |
+
'pm2.5': {'units': 'µg/m³', 'name': 'PM2.5', 'cmap': 'YlOrRd', 'vmax_percentile': 95, 'type': 'surface'},
|
| 152 |
+
'pm10': {'units': 'µg/m³', 'name': 'PM10', 'cmap': 'Oranges', 'vmax_percentile': 95, 'type': 'surface'},
|
| 153 |
+
'pm1': {'units': 'µg/m³', 'name': 'PM1', 'cmap': 'Reds', 'vmax_percentile': 95, 'type': 'surface'},
|
| 154 |
+
'no2': {'units': 'µg/m³', 'name': 'NO₂', 'cmap': 'Reds', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 155 |
+
'nitrogen dioxide': {'units': 'µg/m³', 'name': 'NO₂', 'cmap': 'Reds', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 156 |
+
'so2': {'units': 'µg/m³', 'name': 'SO₂', 'cmap': 'Purples', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 157 |
+
'sulphur dioxide': {'units': 'µg/m³', 'name': 'SO₂', 'cmap': 'Purples', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 158 |
+
'sulfur dioxide': {'units': 'µg/m³', 'name': 'SO₂', 'cmap': 'Purples', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 159 |
+
'ozone': {'units': 'µg/m³', 'name': 'O₃', 'cmap': 'Blues', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 160 |
+
'carbon monoxide': {'units': 'mg/m³', 'name': 'CO', 'cmap': 'Greens', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 161 |
+
'nitrogen monoxide': {'units': 'µg/m³', 'name': 'NO', 'cmap': 'Oranges', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 162 |
+
'ammonia': {'units': 'µg/m³', 'name': 'NH₃', 'cmap': 'viridis', 'vmax_percentile': 90, 'type': 'atmospheric'},
|
| 163 |
+
'particulate': {'units': 'µg/m³', 'name': 'Particulate Matter', 'cmap': 'YlOrRd', 'vmax_percentile': 95, 'type': 'surface'},
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
for keyword, properties in pollution_keywords.items():
|
| 167 |
+
if (keyword in var_name_lower or
|
| 168 |
+
keyword in long_name or
|
| 169 |
+
keyword in standard_name):
|
| 170 |
+
detected[var_name] = properties.copy()
|
| 171 |
+
detected[var_name]['original_name'] = var_name
|
| 172 |
+
detected[var_name]['dataset_type'] = dataset_type
|
| 173 |
+
detected[var_name]['shape'] = dataset[var_name].shape
|
| 174 |
+
detected[var_name]['dims'] = list(dataset[var_name].dims)
|
| 175 |
+
break
|
| 176 |
+
|
| 177 |
+
return detected
|
| 178 |
+
|
| 179 |
+
def get_coordinates(self, dataset):
|
| 180 |
+
"""Get coordinate names from dataset"""
|
| 181 |
+
coords = list(dataset.coords.keys())
|
| 182 |
+
|
| 183 |
+
# Find latitude coordinate
|
| 184 |
+
lat_names = ['latitude', 'lat', 'y', 'Latitude', 'LATITUDE']
|
| 185 |
+
lat_coord = next((name for name in lat_names if name in coords), None)
|
| 186 |
+
|
| 187 |
+
# Find longitude coordinate
|
| 188 |
+
lon_names = ['longitude', 'lon', 'x', 'Longitude', 'LONGITUDE']
|
| 189 |
+
lon_coord = next((name for name in lon_names if name in coords), None)
|
| 190 |
+
|
| 191 |
+
# Find time coordinate
|
| 192 |
+
time_names = ['time', 'Time', 'TIME', 'forecast_reference_time']
|
| 193 |
+
time_coord = next((name for name in time_names if name in coords), None)
|
| 194 |
+
|
| 195 |
+
# Find pressure/level coordinate
|
| 196 |
+
level_names = ['pressure_level', 'plev', 'level', 'pressure', 'lev']
|
| 197 |
+
level_coord = next((name for name in level_names if name in coords), None)
|
| 198 |
+
|
| 199 |
+
return {
|
| 200 |
+
'lat': lat_coord,
|
| 201 |
+
'lon': lon_coord,
|
| 202 |
+
'time': time_coord,
|
| 203 |
+
'level': level_coord
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
def format_timestamp(self, timestamp):
|
| 207 |
+
"""Format timestamp for display in plots"""
|
| 208 |
+
try:
|
| 209 |
+
if pd.isna(timestamp):
|
| 210 |
+
return "Unknown Time"
|
| 211 |
+
|
| 212 |
+
# Convert to pandas datetime if it isn't already
|
| 213 |
+
if not isinstance(timestamp, pd.Timestamp):
|
| 214 |
+
timestamp = pd.to_datetime(timestamp)
|
| 215 |
+
|
| 216 |
+
# Format as "YYYY-MM-DD HH:MM"
|
| 217 |
+
return timestamp.strftime('%Y-%m-%d %H:%M')
|
| 218 |
+
except:
|
| 219 |
+
return str(timestamp)
|
| 220 |
+
|
| 221 |
+
def extract_data(self, variable_name, time_index=1, pressure_level=None):
|
| 222 |
+
"""
|
| 223 |
+
Extract data for a specific variable
|
| 224 |
+
|
| 225 |
+
Parameters:
|
| 226 |
+
variable_name (str): Name of the variable to extract
|
| 227 |
+
time_index (int): Time index to extract (default: 0 for current time)
|
| 228 |
+
pressure_level (float): Pressure level for atmospheric variables (default: surface level)
|
| 229 |
+
|
| 230 |
+
Returns:
|
| 231 |
+
tuple: (data_array, metadata)
|
| 232 |
+
"""
|
| 233 |
+
if variable_name not in self.detected_variables:
|
| 234 |
+
raise ValueError(f"Variable {variable_name} not found in detected variables")
|
| 235 |
+
|
| 236 |
+
var_info = self.detected_variables[variable_name]
|
| 237 |
+
dataset_type = var_info['dataset_type']
|
| 238 |
+
|
| 239 |
+
# Determine which dataset to use
|
| 240 |
+
if dataset_type == 'surface' and self.surface_dataset is not None:
|
| 241 |
+
dataset = self.surface_dataset
|
| 242 |
+
elif dataset_type == 'atmospheric' and self.atmospheric_dataset is not None:
|
| 243 |
+
dataset = self.atmospheric_dataset
|
| 244 |
+
elif self.dataset is not None:
|
| 245 |
+
dataset = self.dataset
|
| 246 |
+
else:
|
| 247 |
+
raise ValueError(f"No suitable dataset found for variable {variable_name}")
|
| 248 |
+
|
| 249 |
+
# Get the data variable
|
| 250 |
+
data_var = dataset[variable_name]
|
| 251 |
+
coords = self.get_coordinates(dataset)
|
| 252 |
+
print(f"Coordinates: {coords}\n\n")
|
| 253 |
+
|
| 254 |
+
# Handle different data shapes
|
| 255 |
+
data_array = data_var
|
| 256 |
+
print(f"Data array shape: {data_array.dims} \n\n")
|
| 257 |
+
|
| 258 |
+
# Get timestamp information before extracting data
|
| 259 |
+
selected_timestamp = None
|
| 260 |
+
timestamp_str = "Unknown Time"
|
| 261 |
+
|
| 262 |
+
# Handle time dimension
|
| 263 |
+
if coords['time'] and coords['time'] in data_array.dims:
|
| 264 |
+
# Get all available times
|
| 265 |
+
available_times = pd.to_datetime(dataset[coords['time']].values)
|
| 266 |
+
|
| 267 |
+
if time_index == -1: # Latest time
|
| 268 |
+
time_index = len(available_times) - 1
|
| 269 |
+
|
| 270 |
+
# Ensure time_index is within bounds
|
| 271 |
+
if 0 <= time_index < len(available_times):
|
| 272 |
+
selected_timestamp = available_times[time_index]
|
| 273 |
+
timestamp_str = self.format_timestamp(selected_timestamp)
|
| 274 |
+
print(f"Time index: {time_index} selected - {timestamp_str}")
|
| 275 |
+
data_array = data_array.isel({coords['time']: time_index})
|
| 276 |
+
else:
|
| 277 |
+
print(f"Warning: time_index {time_index} out of bounds, using index 0")
|
| 278 |
+
time_index = 0
|
| 279 |
+
selected_timestamp = available_times[time_index]
|
| 280 |
+
timestamp_str = self.format_timestamp(selected_timestamp)
|
| 281 |
+
data_array = data_array.isel({coords['time']: time_index})
|
| 282 |
+
|
| 283 |
+
# Handle pressure/level dimension for atmospheric variables
|
| 284 |
+
if coords['level'] and coords['level'] in data_array.dims:
|
| 285 |
+
if pressure_level is None:
|
| 286 |
+
# Default to surface level (highest pressure)
|
| 287 |
+
pressure_level = 1000
|
| 288 |
+
|
| 289 |
+
# Find closest pressure level
|
| 290 |
+
pressure_values = dataset[coords['level']].values
|
| 291 |
+
level_index = np.argmin(np.abs(pressure_values - pressure_level))
|
| 292 |
+
actual_pressure = pressure_values[level_index]
|
| 293 |
+
|
| 294 |
+
data_array = data_array.isel({coords['level']: level_index})
|
| 295 |
+
print(f"Selected pressure level: {actual_pressure} hPa (requested: {pressure_level} hPa)")
|
| 296 |
+
|
| 297 |
+
# Handle batch dimension (usually the first dimension for CAMS data)
|
| 298 |
+
shape = data_array.shape
|
| 299 |
+
if len(shape) == 4: # (batch, time, lat, lon) or similar
|
| 300 |
+
data_array = data_array[0, -1] # Take first batch, latest time
|
| 301 |
+
elif len(shape) == 3: # (batch, lat, lon) or (time, lat, lon)
|
| 302 |
+
data_array = data_array[-1] # Take latest
|
| 303 |
+
elif len(shape) == 5: # (batch, time, level, lat, lon)
|
| 304 |
+
data_array = data_array[0, -1] # Already handled level above
|
| 305 |
+
|
| 306 |
+
# Get coordinate arrays
|
| 307 |
+
lats = dataset[coords['lat']].values
|
| 308 |
+
lons = dataset[coords['lon']].values
|
| 309 |
+
|
| 310 |
+
# Convert units if necessary
|
| 311 |
+
original_units = getattr(dataset[variable_name], 'units', '')
|
| 312 |
+
data_values = self._convert_units(data_array.values, original_units, var_info['units'])
|
| 313 |
+
|
| 314 |
+
metadata = {
|
| 315 |
+
'variable_name': variable_name,
|
| 316 |
+
'display_name': var_info['name'],
|
| 317 |
+
'units': var_info['units'],
|
| 318 |
+
'original_units': original_units,
|
| 319 |
+
'shape': data_values.shape,
|
| 320 |
+
'lats': lats,
|
| 321 |
+
'lons': lons,
|
| 322 |
+
'pressure_level': pressure_level if coords['level'] and coords['level'] in dataset[variable_name].dims else None,
|
| 323 |
+
'time_index': time_index,
|
| 324 |
+
'timestamp': selected_timestamp,
|
| 325 |
+
'timestamp_str': timestamp_str,
|
| 326 |
+
'dataset_type': dataset_type
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
return data_values, metadata
|
| 330 |
+
|
| 331 |
+
def _convert_units(self, data, original_units, target_units):
|
| 332 |
+
"""Convert data units for air pollution variables"""
|
| 333 |
+
data_converted = data.copy()
|
| 334 |
+
|
| 335 |
+
if original_units and target_units:
|
| 336 |
+
orig_lower = original_units.lower()
|
| 337 |
+
target_lower = target_units.lower()
|
| 338 |
+
|
| 339 |
+
# kg/m³ to µg/m³
|
| 340 |
+
if 'kg' in orig_lower and 'µg' in target_lower:
|
| 341 |
+
data_converted = data_converted * 1e9
|
| 342 |
+
print(f"Converting from {original_units} to {target_units} (×1e9)")
|
| 343 |
+
|
| 344 |
+
# kg/m³ to mg/m³
|
| 345 |
+
elif 'kg' in orig_lower and 'mg' in target_lower:
|
| 346 |
+
data_converted = data_converted * 1e6
|
| 347 |
+
print(f"Converting from {original_units} to {target_units} (×1e6)")
|
| 348 |
+
|
| 349 |
+
# mol/m² conversions (keep as is)
|
| 350 |
+
elif 'mol' in orig_lower:
|
| 351 |
+
print(f"Units {original_units} kept as is")
|
| 352 |
+
|
| 353 |
+
# No unit (dimensionless) - keep as is
|
| 354 |
+
elif target_units == '':
|
| 355 |
+
print("Dimensionless variable - no unit conversion")
|
| 356 |
+
|
| 357 |
+
return data_converted
|
| 358 |
+
|
| 359 |
+
def get_available_times(self, variable_name):
|
| 360 |
+
"""Get available time steps for a variable"""
|
| 361 |
+
if variable_name not in self.detected_variables:
|
| 362 |
+
return []
|
| 363 |
+
|
| 364 |
+
var_info = self.detected_variables[variable_name]
|
| 365 |
+
dataset_type = var_info['dataset_type']
|
| 366 |
+
|
| 367 |
+
# Determine which dataset to use
|
| 368 |
+
if dataset_type == 'surface' and self.surface_dataset is not None:
|
| 369 |
+
dataset = self.surface_dataset
|
| 370 |
+
elif dataset_type == 'atmospheric' and self.atmospheric_dataset is not None:
|
| 371 |
+
dataset = self.atmospheric_dataset
|
| 372 |
+
elif self.dataset is not None:
|
| 373 |
+
dataset = self.dataset
|
| 374 |
+
else:
|
| 375 |
+
return []
|
| 376 |
+
|
| 377 |
+
coords = self.get_coordinates(dataset)
|
| 378 |
+
|
| 379 |
+
if coords['time'] and coords['time'] in dataset.dims:
|
| 380 |
+
times = pd.to_datetime(dataset[coords['time']].values)
|
| 381 |
+
print(f"Times: {times.to_list()}")
|
| 382 |
+
return times.tolist()
|
| 383 |
+
|
| 384 |
+
return []
|
| 385 |
+
|
| 386 |
+
def get_available_pressure_levels(self, variable_name):
|
| 387 |
+
"""Get available pressure levels for atmospheric variables"""
|
| 388 |
+
if variable_name not in self.detected_variables:
|
| 389 |
+
return []
|
| 390 |
+
|
| 391 |
+
var_info = self.detected_variables[variable_name]
|
| 392 |
+
if var_info['type'] != 'atmospheric':
|
| 393 |
+
return []
|
| 394 |
+
|
| 395 |
+
dataset_type = var_info['dataset_type']
|
| 396 |
+
|
| 397 |
+
# Determine which dataset to use
|
| 398 |
+
if dataset_type == 'atmospheric' and self.atmospheric_dataset is not None:
|
| 399 |
+
dataset = self.atmospheric_dataset
|
| 400 |
+
elif self.dataset is not None:
|
| 401 |
+
dataset = self.dataset
|
| 402 |
+
else:
|
| 403 |
+
return []
|
| 404 |
+
|
| 405 |
+
coords = self.get_coordinates(dataset)
|
| 406 |
+
|
| 407 |
+
if coords['level'] and coords['level'] in dataset.dims:
|
| 408 |
+
levels = dataset[coords['level']].values
|
| 409 |
+
return levels.tolist()
|
| 410 |
+
|
| 411 |
+
return PRESSURE_LEVELS # Default pressure levels
|
| 412 |
+
|
| 413 |
+
def close(self):
|
| 414 |
+
"""Close all open datasets"""
|
| 415 |
+
if self.dataset is not None:
|
| 416 |
+
self.dataset.close()
|
| 417 |
+
if self.surface_dataset is not None:
|
| 418 |
+
self.surface_dataset.close()
|
| 419 |
+
if self.atmospheric_dataset is not None:
|
| 420 |
+
self.atmospheric_dataset.close()
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def analyze_netcdf_file(file_path):
|
| 424 |
+
"""
|
| 425 |
+
Analyze NetCDF file structure and return summary
|
| 426 |
+
|
| 427 |
+
Parameters:
|
| 428 |
+
file_path (str): Path to NetCDF or ZIP file
|
| 429 |
+
|
| 430 |
+
Returns:
|
| 431 |
+
dict: Analysis summary
|
| 432 |
+
"""
|
| 433 |
+
processor = NetCDFProcessor(file_path)
|
| 434 |
+
|
| 435 |
+
try:
|
| 436 |
+
processor.load_dataset()
|
| 437 |
+
detected_vars = processor.detect_variables()
|
| 438 |
+
|
| 439 |
+
analysis = {
|
| 440 |
+
'success': True,
|
| 441 |
+
'file_path': str(file_path),
|
| 442 |
+
'detected_variables': detected_vars,
|
| 443 |
+
'total_variables': len(detected_vars),
|
| 444 |
+
'surface_variables': len([v for v in detected_vars.values() if v.get('type') == 'surface']),
|
| 445 |
+
'atmospheric_variables': len([v for v in detected_vars.values() if v.get('type') == 'atmospheric']),
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
# Get sample time information
|
| 449 |
+
if detected_vars:
|
| 450 |
+
sample_var = list(detected_vars.keys())[0]
|
| 451 |
+
times = processor.get_available_times(sample_var)
|
| 452 |
+
if times:
|
| 453 |
+
analysis['time_range'] = {
|
| 454 |
+
'start': str(times[0]),
|
| 455 |
+
'end': str(times[-1]),
|
| 456 |
+
'count': len(times)
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
processor.close()
|
| 460 |
+
return analysis
|
| 461 |
+
|
| 462 |
+
except Exception as e:
|
| 463 |
+
processor.close()
|
| 464 |
+
return {
|
| 465 |
+
'success': False,
|
| 466 |
+
'error': str(e),
|
| 467 |
+
'file_path': str(file_path)
|
| 468 |
+
}
|
deploy.sh
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
# Deployment script for Hugging Face Spaces
|
| 4 |
+
|
| 5 |
+
echo "🚀 Preparing CAMS Air Pollution Dashboard for Hugging Face deployment"
|
| 6 |
+
|
| 7 |
+
# Build the Docker image
|
| 8 |
+
echo "📦 Building Docker image..."
|
| 9 |
+
docker build -t cams-pollution-dashboard .
|
| 10 |
+
|
| 11 |
+
# Test the container locally (optional)
|
| 12 |
+
echo "🧪 To test locally, run:"
|
| 13 |
+
echo "docker run -p 7860:7860 cams-pollution-dashboard"
|
| 14 |
+
|
| 15 |
+
echo ""
|
| 16 |
+
echo "📋 Deployment checklist for Hugging Face Spaces:"
|
| 17 |
+
echo "1. Create a new Space on Hugging Face"
|
| 18 |
+
echo "2. Select 'Docker' as the SDK"
|
| 19 |
+
echo "3. Upload all files including Dockerfile"
|
| 20 |
+
echo "4. Make sure app_readme.md contains the correct YAML frontmatter"
|
| 21 |
+
echo "5. Push to your Hugging Face repository"
|
| 22 |
+
|
| 23 |
+
echo ""
|
| 24 |
+
echo "✅ Ready for deployment!"
|
interactive_plot_generator.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# interactive_plot_generator.py
|
| 2 |
+
# Generate interactive air pollution maps for India with hover information
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
import plotly.graph_objects as go
|
| 6 |
+
import plotly.express as px
|
| 7 |
+
import geopandas as gpd
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from constants import INDIA_BOUNDS, COLOR_THEMES
|
| 11 |
+
import warnings
|
| 12 |
+
warnings.filterwarnings('ignore')
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class InteractiveIndiaMapPlotter:
|
| 16 |
+
def __init__(self, plots_dir="plots", shapefile_path="shapefiles/India_State_Boundary.shp"):
|
| 17 |
+
"""
|
| 18 |
+
Initialize the interactive map plotter
|
| 19 |
+
|
| 20 |
+
Parameters:
|
| 21 |
+
plots_dir (str): Directory to save plots
|
| 22 |
+
shapefile_path (str): Path to the India districts shapefile
|
| 23 |
+
"""
|
| 24 |
+
self.plots_dir = Path(plots_dir)
|
| 25 |
+
self.plots_dir.mkdir(exist_ok=True)
|
| 26 |
+
|
| 27 |
+
try:
|
| 28 |
+
self.india_map = gpd.read_file(shapefile_path)
|
| 29 |
+
|
| 30 |
+
# Ensure it's in lat/lon (WGS84)
|
| 31 |
+
if self.india_map.crs is not None and self.india_map.crs.to_epsg() != 4326:
|
| 32 |
+
self.india_map = self.india_map.to_crs(epsg=4326)
|
| 33 |
+
|
| 34 |
+
except Exception as e:
|
| 35 |
+
raise FileNotFoundError(f"Could not read the shapefile at '{shapefile_path}'. "
|
| 36 |
+
f"Please ensure the file exists. Error: {e}")
|
| 37 |
+
|
| 38 |
+
def create_india_map(self, data_values, metadata, color_theme=None, save_plot=True, custom_title=None):
|
| 39 |
+
"""
|
| 40 |
+
Create interactive air pollution map over India with hover information
|
| 41 |
+
|
| 42 |
+
Parameters:
|
| 43 |
+
data_values (np.ndarray): 2D array of pollution data
|
| 44 |
+
metadata (dict): Metadata containing lats, lons, variable info, etc.
|
| 45 |
+
color_theme (str): Color theme name from COLOR_THEMES
|
| 46 |
+
save_plot (bool): Whether to save the plot as JPG
|
| 47 |
+
custom_title (str): Custom title for the plot
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
str: Path to saved plot file
|
| 51 |
+
"""
|
| 52 |
+
try:
|
| 53 |
+
# Extract metadata
|
| 54 |
+
lats = metadata['lats']
|
| 55 |
+
lons = metadata['lons']
|
| 56 |
+
var_name = metadata['variable_name']
|
| 57 |
+
display_name = metadata['display_name']
|
| 58 |
+
units = metadata['units']
|
| 59 |
+
pressure_level = metadata.get('pressure_level')
|
| 60 |
+
time_stamp = metadata.get('timestamp_str')
|
| 61 |
+
|
| 62 |
+
# Determine color theme
|
| 63 |
+
if color_theme is None:
|
| 64 |
+
from constants import AIR_POLLUTION_VARIABLES
|
| 65 |
+
color_theme = AIR_POLLUTION_VARIABLES.get(var_name, {}).get('cmap', 'viridis')
|
| 66 |
+
|
| 67 |
+
# Map matplotlib colormaps to Plotly colormaps
|
| 68 |
+
colormap_mapping = {
|
| 69 |
+
'viridis': 'Viridis',
|
| 70 |
+
'plasma': 'Plasma',
|
| 71 |
+
'inferno': 'Inferno',
|
| 72 |
+
'magma': 'Magma',
|
| 73 |
+
'cividis': 'Cividis',
|
| 74 |
+
'YlOrRd': 'YlOrRd',
|
| 75 |
+
'RdYlGn_r': 'RdYlGn_r',
|
| 76 |
+
'coolwarm': 'RdBu_r',
|
| 77 |
+
'Spectral_r': 'Spectral_r',
|
| 78 |
+
'jet': 'Jet',
|
| 79 |
+
'turbo': 'Turbo'
|
| 80 |
+
}
|
| 81 |
+
plotly_colorscale = colormap_mapping.get(color_theme, 'Viridis')
|
| 82 |
+
|
| 83 |
+
# Create mesh grid if needed
|
| 84 |
+
if lons.ndim == 1 and lats.ndim == 1:
|
| 85 |
+
lon_grid, lat_grid = np.meshgrid(lons, lats)
|
| 86 |
+
else:
|
| 87 |
+
lon_grid, lat_grid = lons, lats
|
| 88 |
+
|
| 89 |
+
# Calculate statistics
|
| 90 |
+
valid_data = data_values[~np.isnan(data_values)]
|
| 91 |
+
if len(valid_data) == 0:
|
| 92 |
+
raise ValueError("All data values are NaN - cannot create plot")
|
| 93 |
+
|
| 94 |
+
from constants import AIR_POLLUTION_VARIABLES
|
| 95 |
+
vmax_percentile = AIR_POLLUTION_VARIABLES.get(var_name, {}).get('vmax_percentile', 90)
|
| 96 |
+
vmin = np.nanpercentile(valid_data, 5)
|
| 97 |
+
vmax = np.nanpercentile(valid_data, vmax_percentile)
|
| 98 |
+
if vmax <= vmin:
|
| 99 |
+
vmax = vmin + 1.0
|
| 100 |
+
|
| 101 |
+
# Create hover text with detailed information
|
| 102 |
+
hover_text = self._create_hover_text(lon_grid, lat_grid, data_values, display_name, units)
|
| 103 |
+
|
| 104 |
+
# Create the figure
|
| 105 |
+
fig = go.Figure()
|
| 106 |
+
|
| 107 |
+
# Add pollution data as heatmap
|
| 108 |
+
fig.add_trace(go.Heatmap(
|
| 109 |
+
x=lons,
|
| 110 |
+
y=lats,
|
| 111 |
+
z=data_values,
|
| 112 |
+
colorscale=plotly_colorscale,
|
| 113 |
+
zmin=vmin,
|
| 114 |
+
zmax=vmax,
|
| 115 |
+
hovertext=hover_text,
|
| 116 |
+
hoverinfo='text',
|
| 117 |
+
colorbar=dict(
|
| 118 |
+
title=dict(
|
| 119 |
+
text=f"{display_name}" + (f"<br>({units})" if units else ""),
|
| 120 |
+
side="right"
|
| 121 |
+
),
|
| 122 |
+
thickness=20,
|
| 123 |
+
len=0.6,
|
| 124 |
+
x=1.02
|
| 125 |
+
)
|
| 126 |
+
))
|
| 127 |
+
|
| 128 |
+
# Add India state boundaries
|
| 129 |
+
for _, row in self.india_map.iterrows():
|
| 130 |
+
if row.geometry.geom_type == 'Polygon':
|
| 131 |
+
self._add_polygon_trace(fig, row.geometry)
|
| 132 |
+
elif row.geometry.geom_type == 'MultiPolygon':
|
| 133 |
+
for polygon in row.geometry.geoms:
|
| 134 |
+
self._add_polygon_trace(fig, polygon)
|
| 135 |
+
|
| 136 |
+
# Create title
|
| 137 |
+
if custom_title:
|
| 138 |
+
title = custom_title
|
| 139 |
+
else:
|
| 140 |
+
title = f'{display_name} Concentration over India'
|
| 141 |
+
if pressure_level:
|
| 142 |
+
title += f' at {pressure_level} hPa'
|
| 143 |
+
title += f' on {time_stamp}'
|
| 144 |
+
|
| 145 |
+
# Calculate stats for annotation
|
| 146 |
+
stats_text = self._create_stats_text(valid_data, units)
|
| 147 |
+
theme_name = COLOR_THEMES.get(color_theme, color_theme)
|
| 148 |
+
|
| 149 |
+
# Auto-adjust bounds if needed
|
| 150 |
+
xmin, ymin, xmax, ymax = self.india_map.total_bounds
|
| 151 |
+
if not (INDIA_BOUNDS['lon_min'] <= xmin <= INDIA_BOUNDS['lon_max']):
|
| 152 |
+
lon_range = [xmin, xmax]
|
| 153 |
+
lat_range = [ymin, ymax]
|
| 154 |
+
else:
|
| 155 |
+
lon_range = [INDIA_BOUNDS['lon_min'], INDIA_BOUNDS['lon_max']]
|
| 156 |
+
lat_range = [INDIA_BOUNDS['lat_min'], INDIA_BOUNDS['lat_max']]
|
| 157 |
+
|
| 158 |
+
# Update layout
|
| 159 |
+
fig.update_layout(
|
| 160 |
+
title=dict(
|
| 161 |
+
text=title,
|
| 162 |
+
x=0.5,
|
| 163 |
+
xanchor='center',
|
| 164 |
+
font=dict(size=18, weight='bold')
|
| 165 |
+
),
|
| 166 |
+
xaxis=dict(
|
| 167 |
+
title='Longitude',
|
| 168 |
+
range=lon_range,
|
| 169 |
+
showgrid=True,
|
| 170 |
+
gridcolor='rgba(128, 128, 128, 0.3)'
|
| 171 |
+
),
|
| 172 |
+
yaxis=dict(
|
| 173 |
+
title='Latitude',
|
| 174 |
+
range=lat_range,
|
| 175 |
+
showgrid=True,
|
| 176 |
+
gridcolor='rgba(128, 128, 128, 0.3)'
|
| 177 |
+
),
|
| 178 |
+
width=1400,
|
| 179 |
+
height=1000,
|
| 180 |
+
plot_bgcolor='white',
|
| 181 |
+
annotations=[
|
| 182 |
+
# Statistics box
|
| 183 |
+
dict(
|
| 184 |
+
text=stats_text.replace('\n', '<br>'),
|
| 185 |
+
xref='paper', yref='paper',
|
| 186 |
+
x=0.02, y=0.98,
|
| 187 |
+
xanchor='left', yanchor='top',
|
| 188 |
+
showarrow=False,
|
| 189 |
+
bgcolor='rgba(255, 255, 255, 0.9)',
|
| 190 |
+
bordercolor='black',
|
| 191 |
+
borderwidth=1,
|
| 192 |
+
borderpad=10,
|
| 193 |
+
font=dict(size=11)
|
| 194 |
+
),
|
| 195 |
+
# Theme info box
|
| 196 |
+
dict(
|
| 197 |
+
text=f'Color Theme: {theme_name}',
|
| 198 |
+
xref='paper', yref='paper',
|
| 199 |
+
x=0.98, y=0.02,
|
| 200 |
+
xanchor='right', yanchor='bottom',
|
| 201 |
+
showarrow=False,
|
| 202 |
+
bgcolor='rgba(211, 211, 211, 0.8)',
|
| 203 |
+
bordercolor='gray',
|
| 204 |
+
borderwidth=1,
|
| 205 |
+
borderpad=8,
|
| 206 |
+
font=dict(size=10)
|
| 207 |
+
)
|
| 208 |
+
]
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
plot_path = None
|
| 212 |
+
if save_plot:
|
| 213 |
+
plot_path = self._save_plot(fig, var_name, display_name, pressure_level, color_theme, time_stamp)
|
| 214 |
+
|
| 215 |
+
return plot_path
|
| 216 |
+
|
| 217 |
+
except Exception as e:
|
| 218 |
+
raise Exception(f"Error creating interactive map: {str(e)}")
|
| 219 |
+
|
| 220 |
+
def _add_polygon_trace(self, fig, polygon):
|
| 221 |
+
"""Add a polygon boundary to the figure"""
|
| 222 |
+
x, y = polygon.exterior.xy
|
| 223 |
+
fig.add_trace(go.Scatter(
|
| 224 |
+
x=list(x),
|
| 225 |
+
y=list(y),
|
| 226 |
+
mode='lines',
|
| 227 |
+
line=dict(color='black', width=1),
|
| 228 |
+
hoverinfo='skip',
|
| 229 |
+
showlegend=False
|
| 230 |
+
))
|
| 231 |
+
|
| 232 |
+
def _create_hover_text(self, lon_grid, lat_grid, data_values, display_name, units):
|
| 233 |
+
"""Create formatted hover text for each point"""
|
| 234 |
+
hover_text = np.empty(data_values.shape, dtype=object)
|
| 235 |
+
units_str = f" {units}" if units else ""
|
| 236 |
+
|
| 237 |
+
for i in range(data_values.shape[0]):
|
| 238 |
+
for j in range(data_values.shape[1]):
|
| 239 |
+
lat = lat_grid[i, j] if lat_grid.ndim == 2 else lat_grid[i]
|
| 240 |
+
lon = lon_grid[i, j] if lon_grid.ndim == 2 else lon_grid[j]
|
| 241 |
+
value = data_values[i, j]
|
| 242 |
+
|
| 243 |
+
if np.isnan(value):
|
| 244 |
+
value_str = "N/A"
|
| 245 |
+
elif abs(value) >= 1000:
|
| 246 |
+
value_str = f"{value:.0f}{units_str}"
|
| 247 |
+
elif abs(value) >= 10:
|
| 248 |
+
value_str = f"{value:.1f}{units_str}"
|
| 249 |
+
else:
|
| 250 |
+
value_str = f"{value:.2f}{units_str}"
|
| 251 |
+
|
| 252 |
+
hover_text[i, j] = (
|
| 253 |
+
f"<b>{display_name}</b>: {value_str}<br>"
|
| 254 |
+
f"<b>Latitude</b>: {lat:.3f}°<br>"
|
| 255 |
+
f"<b>Longitude</b>: {lon:.3f}°"
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
return hover_text
|
| 259 |
+
|
| 260 |
+
def _create_stats_text(self, data, units):
|
| 261 |
+
"""Create statistics text for annotation"""
|
| 262 |
+
units_str = f" {units}" if units else ""
|
| 263 |
+
stats = {
|
| 264 |
+
'Min': np.nanmin(data),
|
| 265 |
+
'Max': np.nanmax(data),
|
| 266 |
+
'Mean': np.nanmean(data),
|
| 267 |
+
'Median': np.nanmedian(data),
|
| 268 |
+
'Std': np.nanstd(data)
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
def format_number(val):
|
| 272 |
+
if abs(val) >= 1000:
|
| 273 |
+
return f"{val:.0f}"
|
| 274 |
+
elif abs(val) >= 10:
|
| 275 |
+
return f"{val:.1f}"
|
| 276 |
+
else:
|
| 277 |
+
return f"{val:.2f}"
|
| 278 |
+
|
| 279 |
+
stats_lines = [f"{name}: {format_number(val)}{units_str}" for name, val in stats.items()]
|
| 280 |
+
return "\n".join(stats_lines)
|
| 281 |
+
|
| 282 |
+
def _save_plot(self, fig, var_name, display_name, pressure_level, color_theme, time_stamp):
|
| 283 |
+
"""Save the plot as JPG"""
|
| 284 |
+
safe_display_name = display_name.replace('/', '_').replace(' ', '_').replace('₂', '2').replace('₃', '3').replace('.', '_')
|
| 285 |
+
safe_time_stamp = time_stamp.replace('-', '').replace(':', '').replace(' ', '_')
|
| 286 |
+
|
| 287 |
+
filename_parts = [f"{safe_display_name}_India_interactive"]
|
| 288 |
+
if pressure_level:
|
| 289 |
+
filename_parts.append(f"{int(pressure_level)}hPa")
|
| 290 |
+
filename_parts.extend([color_theme, safe_time_stamp])
|
| 291 |
+
filename = "_".join(filename_parts) + ".jpg"
|
| 292 |
+
|
| 293 |
+
plot_path = self.plots_dir / filename
|
| 294 |
+
|
| 295 |
+
# Save as static JPG with high quality
|
| 296 |
+
fig.write_image(str(plot_path), format='jpg', width=1400, height=1000, scale=2)
|
| 297 |
+
print(f"Interactive plot saved as JPG: {plot_path}")
|
| 298 |
+
|
| 299 |
+
return str(plot_path)
|
| 300 |
+
|
| 301 |
+
def list_available_themes(self):
|
| 302 |
+
"""List available color themes"""
|
| 303 |
+
return COLOR_THEMES
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def test_interactive_plot_generator():
|
| 307 |
+
"""Test function for the interactive plot generator"""
|
| 308 |
+
print("Testing interactive plot generator...")
|
| 309 |
+
|
| 310 |
+
# Create test data
|
| 311 |
+
lats = np.linspace(6, 38, 50)
|
| 312 |
+
lons = np.linspace(68, 98, 60)
|
| 313 |
+
lon_grid, lat_grid = np.meshgrid(lons, lats)
|
| 314 |
+
data = np.sin(lat_grid * 0.1) * np.cos(lon_grid * 0.1) * 100 + 50
|
| 315 |
+
data += np.random.normal(0, 10, data.shape)
|
| 316 |
+
|
| 317 |
+
metadata = {
|
| 318 |
+
'variable_name': 'pm25',
|
| 319 |
+
'display_name': 'PM2.5',
|
| 320 |
+
'units': 'µg/m³',
|
| 321 |
+
'lats': lats,
|
| 322 |
+
'lons': lons,
|
| 323 |
+
'pressure_level': None,
|
| 324 |
+
'timestamp_str': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
shapefile_path = "shapefiles/India_State_Boundary.shp"
|
| 328 |
+
if not Path(shapefile_path).exists():
|
| 329 |
+
print(f"❌ Test failed: Shapefile not found at '{shapefile_path}'.")
|
| 330 |
+
print("Please make sure you have unzipped 'India_State_Boundary.zip' into a 'shapefiles' folder.")
|
| 331 |
+
return False
|
| 332 |
+
|
| 333 |
+
plotter = InteractiveIndiaMapPlotter(shapefile_path=shapefile_path)
|
| 334 |
+
|
| 335 |
+
try:
|
| 336 |
+
plot_path = plotter.create_india_map(data, metadata, color_theme='YlOrRd')
|
| 337 |
+
print(f"✅ Test interactive plot created successfully: {plot_path}")
|
| 338 |
+
return True
|
| 339 |
+
except Exception as e:
|
| 340 |
+
print(f"❌ Test failed: {str(e)}")
|
| 341 |
+
import traceback
|
| 342 |
+
traceback.print_exc()
|
| 343 |
+
return False
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
if __name__ == "__main__":
|
| 347 |
+
test_interactive_plot_generator()
|
plot_generator.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# plot_generator.py
|
| 2 |
+
# Generate air pollution maps for India using GeoPandas for the map outline
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
import matplotlib.pyplot as plt
|
| 6 |
+
import matplotlib
|
| 7 |
+
matplotlib.use('Agg') # Use non-interactive backend for web apps
|
| 8 |
+
import geopandas as gpd
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from constants import INDIA_BOUNDS, COLOR_THEMES
|
| 12 |
+
import warnings
|
| 13 |
+
warnings.filterwarnings('ignore')
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class IndiaMapPlotter:
|
| 17 |
+
def __init__(self, plots_dir="plots", shapefile_path="shapefiles/India_State_Boundary.shp"):
|
| 18 |
+
"""
|
| 19 |
+
Initialize the map plotter
|
| 20 |
+
|
| 21 |
+
Parameters:
|
| 22 |
+
plots_dir (str): Directory to save plots
|
| 23 |
+
shapefile_path (str): Path to the India districts shapefile
|
| 24 |
+
"""
|
| 25 |
+
self.plots_dir = Path(plots_dir)
|
| 26 |
+
self.plots_dir.mkdir(exist_ok=True)
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
self.india_map = gpd.read_file(shapefile_path)
|
| 30 |
+
|
| 31 |
+
# Ensure it's in lat/lon (WGS84)
|
| 32 |
+
if self.india_map.crs is not None and self.india_map.crs.to_epsg() != 4326:
|
| 33 |
+
self.india_map = self.india_map.to_crs(epsg=4326)
|
| 34 |
+
|
| 35 |
+
except Exception as e:
|
| 36 |
+
raise FileNotFoundError(f"Could not read the shapefile at '{shapefile_path}'. "
|
| 37 |
+
f"Please ensure the file exists. Error: {e}")
|
| 38 |
+
|
| 39 |
+
plt.rcParams['figure.dpi'] = 300
|
| 40 |
+
plt.rcParams['savefig.dpi'] = 300
|
| 41 |
+
plt.rcParams['savefig.bbox'] = 'tight'
|
| 42 |
+
plt.rcParams['font.size'] = 10
|
| 43 |
+
|
| 44 |
+
def create_india_map(self, data_values, metadata, color_theme=None, save_plot=True, custom_title=None):
|
| 45 |
+
"""
|
| 46 |
+
Create air pollution map over India
|
| 47 |
+
"""
|
| 48 |
+
try:
|
| 49 |
+
# Metadata extraction remains the same
|
| 50 |
+
lats = metadata['lats']
|
| 51 |
+
lons = metadata['lons']
|
| 52 |
+
var_name = metadata['variable_name']
|
| 53 |
+
display_name = metadata['display_name']
|
| 54 |
+
units = metadata['units']
|
| 55 |
+
pressure_level = metadata.get('pressure_level')
|
| 56 |
+
time_stamp = metadata.get('timestamp_str')
|
| 57 |
+
|
| 58 |
+
# Color theme logic remains the same
|
| 59 |
+
if color_theme is None:
|
| 60 |
+
from constants import AIR_POLLUTION_VARIABLES
|
| 61 |
+
color_theme = AIR_POLLUTION_VARIABLES.get(var_name, {}).get('cmap', 'viridis')
|
| 62 |
+
if color_theme not in COLOR_THEMES:
|
| 63 |
+
print(f"Warning: Color theme '{color_theme}' not found, using 'viridis'")
|
| 64 |
+
color_theme = 'viridis'
|
| 65 |
+
|
| 66 |
+
# Create figure and axes
|
| 67 |
+
fig = plt.figure(figsize=(14, 10))
|
| 68 |
+
ax = fig.add_subplot(1, 1, 1)
|
| 69 |
+
|
| 70 |
+
# Set map extent
|
| 71 |
+
ax.set_xlim(INDIA_BOUNDS['lon_min'], INDIA_BOUNDS['lon_max'])
|
| 72 |
+
ax.set_ylim(INDIA_BOUNDS['lat_min'], INDIA_BOUNDS['lat_max'])
|
| 73 |
+
|
| 74 |
+
# --- KEY CHANGE: PLOT ORDER & ZORDER ---
|
| 75 |
+
|
| 76 |
+
# 1. Plot the pollution data in the background (lower zorder)
|
| 77 |
+
if lons.ndim == 1 and lats.ndim == 1:
|
| 78 |
+
lon_grid, lat_grid = np.meshgrid(lons, lats)
|
| 79 |
+
else:
|
| 80 |
+
lon_grid, lat_grid = lons, lats
|
| 81 |
+
|
| 82 |
+
valid_data = data_values[~np.isnan(data_values)]
|
| 83 |
+
if len(valid_data) == 0:
|
| 84 |
+
raise ValueError("All data values are NaN - cannot create plot")
|
| 85 |
+
|
| 86 |
+
from constants import AIR_POLLUTION_VARIABLES
|
| 87 |
+
vmax_percentile = AIR_POLLUTION_VARIABLES.get(var_name, {}).get('vmax_percentile', 90)
|
| 88 |
+
vmin = np.nanpercentile(valid_data, 5)
|
| 89 |
+
vmax = np.nanpercentile(valid_data, vmax_percentile)
|
| 90 |
+
if vmax <= vmin:
|
| 91 |
+
vmax = vmin + 1.0
|
| 92 |
+
|
| 93 |
+
levels = np.linspace(vmin, vmax, 25)
|
| 94 |
+
contour = ax.contourf(lon_grid, lat_grid, data_values,
|
| 95 |
+
levels=levels, cmap=color_theme, extend='max',
|
| 96 |
+
zorder=1)
|
| 97 |
+
|
| 98 |
+
# Auto-adjust bounds if INDIA_BOUNDS is too small or wrong
|
| 99 |
+
xmin, ymin, xmax, ymax = self.india_map.total_bounds
|
| 100 |
+
if not (INDIA_BOUNDS['lon_min'] <= xmin <= INDIA_BOUNDS['lon_max'] and INDIA_BOUNDS['lon_min'] <= xmax <= INDIA_BOUNDS['lon_max']):
|
| 101 |
+
print("⚠️ Warning: Using shapefile's actual bounds instead of INDIA_BOUNDS.")
|
| 102 |
+
ax.set_xlim(xmin, xmax)
|
| 103 |
+
ax.set_ylim(ymin, ymax)
|
| 104 |
+
|
| 105 |
+
# 2. Plot the India map outlines on top of the data (higher zorder)
|
| 106 |
+
self.india_map.plot(ax=ax, edgecolor='black', facecolor='none',
|
| 107 |
+
linewidth=0.8, zorder=2) # <-- CHANGED: Set zorder=2 (foreground)
|
| 108 |
+
|
| 109 |
+
# Add colorbar
|
| 110 |
+
cbar = plt.colorbar(contour, ax=ax, shrink=0.6, pad=0.02, aspect=30)
|
| 111 |
+
cbar_label = f"{display_name}" + (f" ({units})" if units else "")
|
| 112 |
+
cbar.set_label(cbar_label, fontsize=12, labelpad=15)
|
| 113 |
+
|
| 114 |
+
# Add gridlines and labels
|
| 115 |
+
ax.grid(True, linestyle='--', alpha=0.6, color='gray', zorder=3)
|
| 116 |
+
ax.set_xlabel("Longitude", fontsize=10)
|
| 117 |
+
ax.set_ylabel("Latitude", fontsize=10)
|
| 118 |
+
ax.tick_params(axis='both', which='major', labelsize=10)
|
| 119 |
+
|
| 120 |
+
# Title creation logic remains the same
|
| 121 |
+
if custom_title:
|
| 122 |
+
title = custom_title
|
| 123 |
+
else:
|
| 124 |
+
title = f'{display_name} Concentration over India'
|
| 125 |
+
if pressure_level: title += f' at {pressure_level} hPa'
|
| 126 |
+
title += f' on {time_stamp}'
|
| 127 |
+
plt.title(title, fontsize=14, pad=20, weight='bold')
|
| 128 |
+
|
| 129 |
+
# Statistics and theme info boxes remain the same
|
| 130 |
+
stats_text = self._create_stats_text(valid_data, units)
|
| 131 |
+
ax.text(0.02, 0.98, stats_text, transform=ax.transAxes,
|
| 132 |
+
bbox=dict(boxstyle="round,pad=0.5", facecolor="white", alpha=0.9),
|
| 133 |
+
verticalalignment='top', fontsize=10, zorder=4)
|
| 134 |
+
|
| 135 |
+
theme_text = f"Color Theme: {COLOR_THEMES[color_theme]}"
|
| 136 |
+
ax.text(0.98, 0.02, theme_text, transform=ax.transAxes,
|
| 137 |
+
bbox=dict(boxstyle="round,pad=0.3", facecolor="lightgray", alpha=0.8),
|
| 138 |
+
horizontalalignment='right', verticalalignment='bottom', fontsize=9, zorder=4)
|
| 139 |
+
|
| 140 |
+
plt.tight_layout()
|
| 141 |
+
|
| 142 |
+
plot_path = None
|
| 143 |
+
if save_plot:
|
| 144 |
+
plot_path = self._save_plot(fig, var_name, display_name, pressure_level, color_theme, time_stamp)
|
| 145 |
+
|
| 146 |
+
plt.close(fig)
|
| 147 |
+
return plot_path
|
| 148 |
+
|
| 149 |
+
except Exception as e:
|
| 150 |
+
plt.close('all')
|
| 151 |
+
raise Exception(f"Error creating map: {str(e)}")
|
| 152 |
+
|
| 153 |
+
# All other helper methods (_create_stats_text, _save_plot, etc.) are unchanged.
|
| 154 |
+
# The `create_comparison_plot` method is also left out for brevity but would need the same zorder fix.
|
| 155 |
+
# The full, unchanged code for the helper methods from the previous answer is still valid.
|
| 156 |
+
|
| 157 |
+
def _create_stats_text(self, data, units):
|
| 158 |
+
units_str = f" {units}" if units else ""
|
| 159 |
+
stats = {'Min': np.nanmin(data), 'Max': np.nanmax(data), 'Mean': np.nanmean(data), 'Median': np.nanmedian(data), 'Std': np.nanstd(data)}
|
| 160 |
+
def format_number(val):
|
| 161 |
+
if abs(val) >= 1000: return f"{val:.0f}"
|
| 162 |
+
elif abs(val) >= 10: return f"{val:.1f}"
|
| 163 |
+
else: return f"{val:.2f}"
|
| 164 |
+
stats_lines = [f"{name}: {format_number(val)}{units_str}" for name, val in stats.items()]
|
| 165 |
+
return "\n".join(stats_lines)
|
| 166 |
+
|
| 167 |
+
def _save_plot(self, fig, var_name, display_name, pressure_level, color_theme, time_stamp):
|
| 168 |
+
safe_display_name = display_name.replace('/', '_').replace(' ', '_').replace('₂', '2').replace('₃', '3').replace('.', '_')
|
| 169 |
+
safe_time_stamp = time_stamp.replace('-', '').replace(':', '').replace(' ', '_')
|
| 170 |
+
filename_parts = [f"{safe_display_name}_India"]
|
| 171 |
+
if pressure_level:
|
| 172 |
+
filename_parts.append(f"{int(pressure_level)}hPa")
|
| 173 |
+
filename_parts.extend([color_theme, safe_time_stamp])
|
| 174 |
+
filename = "_".join(filename_parts) + ".png"
|
| 175 |
+
plot_path = self.plots_dir / filename
|
| 176 |
+
fig.savefig(plot_path, dpi=300, bbox_inches='tight', facecolor='white', edgecolor='none')
|
| 177 |
+
print(f"Plot saved: {plot_path}")
|
| 178 |
+
return str(plot_path)
|
| 179 |
+
|
| 180 |
+
def list_available_themes(self):
|
| 181 |
+
return COLOR_THEMES
|
| 182 |
+
|
| 183 |
+
def test_plot_generator():
|
| 184 |
+
print("Testing plot generator with GeoPandas and zorder fix...")
|
| 185 |
+
|
| 186 |
+
lats, lons = np.linspace(6, 38, 50), np.linspace(68, 98, 60)
|
| 187 |
+
lon_grid, lat_grid = np.meshgrid(lons, lats)
|
| 188 |
+
data = np.sin(lat_grid * 0.1) * np.cos(lon_grid * 0.1) * 100 + 50
|
| 189 |
+
data += np.random.normal(0, 10, data.shape)
|
| 190 |
+
|
| 191 |
+
metadata = {
|
| 192 |
+
'variable_name': 'pm25', 'display_name': 'PM2.5', 'units': 'µg/m³',
|
| 193 |
+
'lats': lats, 'lons': lons, 'pressure_level': None,
|
| 194 |
+
'timestamp_str': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
shapefile_path = "shapefiles/India_State_Boundary.shp"
|
| 198 |
+
if not Path(shapefile_path).exists():
|
| 199 |
+
print(f"❌ Test failed: Shapefile not found at '{shapefile_path}'.")
|
| 200 |
+
print("Please make sure you have unzipped 'India_State_Boundary.zip' into a 'shapefiles' folder.")
|
| 201 |
+
return False
|
| 202 |
+
|
| 203 |
+
plotter = IndiaMapPlotter(shapefile_path=shapefile_path)
|
| 204 |
+
|
| 205 |
+
try:
|
| 206 |
+
plot_path = plotter.create_india_map(data, metadata, color_theme='YlOrRd')
|
| 207 |
+
print(f"✅ Test plot created successfully: {plot_path}")
|
| 208 |
+
return True
|
| 209 |
+
except Exception as e:
|
| 210 |
+
print(f"❌ Test failed: {str(e)}")
|
| 211 |
+
import traceback
|
| 212 |
+
traceback.print_exc()
|
| 213 |
+
return False
|
| 214 |
+
|
| 215 |
+
if __name__ == "__main__":
|
| 216 |
+
test_plot_generator()
|
requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask==2.3.3
|
| 2 |
+
numpy==1.24.3
|
| 3 |
+
pandas==2.0.3
|
| 4 |
+
matplotlib==3.7.2
|
| 5 |
+
cartopy==0.22.0
|
| 6 |
+
xarray==2023.8.0
|
| 7 |
+
netcdf4==1.6.4
|
| 8 |
+
cdsapi==0.7.4
|
| 9 |
+
werkzeug==2.3.7
|
| 10 |
+
jinja2==3.1.2
|
| 11 |
+
python-dateutil==2.8.2
|
| 12 |
+
plotly==6.3.0
|
| 13 |
+
kaleido
|
| 14 |
+
geopandas
|
templates/index.html
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>CAMS Air Pollution Visualization</title>
|
| 7 |
+
<style>
|
| 8 |
+
body {
|
| 9 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 10 |
+
max-width: 1200px;
|
| 11 |
+
margin: 0 auto;
|
| 12 |
+
padding: 20px;
|
| 13 |
+
background: #f5f5f5;
|
| 14 |
+
}
|
| 15 |
+
.container {
|
| 16 |
+
background: white;
|
| 17 |
+
padding: 30px;
|
| 18 |
+
border-radius: 10px;
|
| 19 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
| 20 |
+
margin-bottom: 20px;
|
| 21 |
+
}
|
| 22 |
+
h1 { color: #2c3e50; text-align: center; margin-bottom: 30px; }
|
| 23 |
+
h2 { color: #34495e; border-bottom: 2px solid #3498db; padding-bottom: 10px; }
|
| 24 |
+
.method-section {
|
| 25 |
+
background: #f8f9fa;
|
| 26 |
+
padding: 20px;
|
| 27 |
+
border-radius: 8px;
|
| 28 |
+
margin-bottom: 20px;
|
| 29 |
+
border-left: 4px solid #3498db;
|
| 30 |
+
}
|
| 31 |
+
.form-group {
|
| 32 |
+
margin-bottom: 15px;
|
| 33 |
+
}
|
| 34 |
+
label {
|
| 35 |
+
display: block;
|
| 36 |
+
margin-bottom: 5px;
|
| 37 |
+
font-weight: 600;
|
| 38 |
+
color: #2c3e50;
|
| 39 |
+
}
|
| 40 |
+
input[type="file"], select, input[type="date"] {
|
| 41 |
+
width: 100%;
|
| 42 |
+
padding: 10px;
|
| 43 |
+
border: 2px solid #ddd;
|
| 44 |
+
border-radius: 5px;
|
| 45 |
+
font-size: 14px;
|
| 46 |
+
}
|
| 47 |
+
input[type="file"]:focus, select:focus, input[type="date"]:focus {
|
| 48 |
+
border-color: #3498db;
|
| 49 |
+
outline: none;
|
| 50 |
+
}
|
| 51 |
+
.btn {
|
| 52 |
+
background: #3498db;
|
| 53 |
+
color: white;
|
| 54 |
+
padding: 12px 24px;
|
| 55 |
+
border: none;
|
| 56 |
+
border-radius: 5px;
|
| 57 |
+
cursor: pointer;
|
| 58 |
+
font-size: 16px;
|
| 59 |
+
font-weight: 600;
|
| 60 |
+
transition: background 0.3s;
|
| 61 |
+
}
|
| 62 |
+
.btn:hover { background: #2980b9; }
|
| 63 |
+
.btn:disabled {
|
| 64 |
+
background: #bdc3c7;
|
| 65 |
+
cursor: not-allowed;
|
| 66 |
+
}
|
| 67 |
+
.alert {
|
| 68 |
+
padding: 15px;
|
| 69 |
+
margin-bottom: 20px;
|
| 70 |
+
border-radius: 5px;
|
| 71 |
+
font-weight: 500;
|
| 72 |
+
}
|
| 73 |
+
.alert-success { background: #d4edda; border: 1px solid #c3e6cb; color: #155724; }
|
| 74 |
+
.alert-error { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; }
|
| 75 |
+
.alert-warning { background: #fff3cd; border: 1px solid #ffeaa7; color: #856404; }
|
| 76 |
+
.info-box {
|
| 77 |
+
background: #e8f4fd;
|
| 78 |
+
border: 1px solid #bee5eb;
|
| 79 |
+
padding: 15px;
|
| 80 |
+
border-radius: 5px;
|
| 81 |
+
margin-bottom: 20px;
|
| 82 |
+
}
|
| 83 |
+
.status-indicator {
|
| 84 |
+
display: inline-block;
|
| 85 |
+
padding: 4px 8px;
|
| 86 |
+
border-radius: 3px;
|
| 87 |
+
font-size: 12px;
|
| 88 |
+
font-weight: bold;
|
| 89 |
+
margin-left: 10px;
|
| 90 |
+
}
|
| 91 |
+
.status-ready { background: #d4edda; color: #155724; }
|
| 92 |
+
.status-error { background: #f8d7da; color: #721c24; }
|
| 93 |
+
.file-info {
|
| 94 |
+
background: #f8f9fa;
|
| 95 |
+
padding: 10px;
|
| 96 |
+
border-radius: 5px;
|
| 97 |
+
margin-top: 10px;
|
| 98 |
+
font-size: 14px;
|
| 99 |
+
}
|
| 100 |
+
.download-list {
|
| 101 |
+
max-height: 200px;
|
| 102 |
+
overflow-y: auto;
|
| 103 |
+
border: 1px solid #ddd;
|
| 104 |
+
border-radius: 5px;
|
| 105 |
+
}
|
| 106 |
+
.download-item {
|
| 107 |
+
padding: 10px;
|
| 108 |
+
border-bottom: 1px solid #eee;
|
| 109 |
+
display: flex;
|
| 110 |
+
justify-content: between;
|
| 111 |
+
align-items: center;
|
| 112 |
+
}
|
| 113 |
+
.download-item:last-child { border-bottom: none; }
|
| 114 |
+
.cleanup-section {
|
| 115 |
+
background: #fff3cd;
|
| 116 |
+
padding: 15px;
|
| 117 |
+
border-radius: 5px;
|
| 118 |
+
margin-top: 20px;
|
| 119 |
+
}
|
| 120 |
+
.two-column {
|
| 121 |
+
display: grid;
|
| 122 |
+
grid-template-columns: 1fr 1fr;
|
| 123 |
+
gap: 20px;
|
| 124 |
+
}
|
| 125 |
+
@media (max-width: 768px) {
|
| 126 |
+
.two-column { grid-template-columns: 1fr; }
|
| 127 |
+
body { padding: 10px; }
|
| 128 |
+
.container { padding: 20px; }
|
| 129 |
+
}
|
| 130 |
+
</style>
|
| 131 |
+
</head>
|
| 132 |
+
<body>
|
| 133 |
+
<div class="container">
|
| 134 |
+
<h1>🌍 CAMS Air Pollution Visualization</h1>
|
| 135 |
+
|
| 136 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 137 |
+
{% if messages %}
|
| 138 |
+
{% for category, message in messages %}
|
| 139 |
+
<div class="alert alert-{{ category }}">{{ message }}</div>
|
| 140 |
+
{% endfor %}
|
| 141 |
+
{% endif %}
|
| 142 |
+
{% endwith %}
|
| 143 |
+
|
| 144 |
+
<div class="info-box">
|
| 145 |
+
<strong>📡 System Status:</strong>
|
| 146 |
+
<span class="status-indicator {% if cds_ready %}status-ready{% else %}status-error{% endif %}">
|
| 147 |
+
{% if cds_ready %}✅ CDS API Ready{% else %}❌ CDS API Not Configured{% endif %}
|
| 148 |
+
</span>
|
| 149 |
+
{% if not cds_ready %}
|
| 150 |
+
<p style="margin: 10px 0 0 0; font-size: 14px; color: #721c24;">
|
| 151 |
+
Please create a .cdsapirc file with your CDS credentials to enable data download.
|
| 152 |
+
</p>
|
| 153 |
+
{% endif %}
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
<div class="two-column">
|
| 157 |
+
<div class="method-section">
|
| 158 |
+
<h2>📁 Method 1: Upload File</h2>
|
| 159 |
+
<p>Upload your own NetCDF (.nc) or ZIP (.zip) file containing CAMS data</p>
|
| 160 |
+
|
| 161 |
+
<form action="/upload" method="post" enctype="multipart/form-data">
|
| 162 |
+
<div class="form-group">
|
| 163 |
+
<label for="file">Select File (.nc or .zip):</label>
|
| 164 |
+
<input type="file" name="file" id="file" accept=".nc,.zip" required>
|
| 165 |
+
<div class="file-info">
|
| 166 |
+
<strong>Supported formats:</strong> NetCDF (.nc) or ZIP (.zip)<br>
|
| 167 |
+
<strong>Maximum size:</strong> 500MB
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
<button type="submit" class="btn">📤 Upload & Analyze</button>
|
| 171 |
+
</form>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
<div class="method-section">
|
| 175 |
+
<h2>📅 Method 2: Download by Date</h2>
|
| 176 |
+
<p>Download CAMS data for a specific date (requires CDS API)</p>
|
| 177 |
+
|
| 178 |
+
<form action="/download_date" method="post">
|
| 179 |
+
<div class="form-group">
|
| 180 |
+
<label for="date_input">Select Date (YYYY-MM-DD):</label>
|
| 181 |
+
<input type="date" name="date" id="date_input" required
|
| 182 |
+
min="2015-01-01" max="{{ current_date }}">
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
<button type="submit" class="btn" id="download_btn" {% if not cds_ready %}disabled{% endif %}>
|
| 186 |
+
⬇️ Download & Analyze
|
| 187 |
+
</button>
|
| 188 |
+
|
| 189 |
+
{% if not cds_ready %}
|
| 190 |
+
<p style="margin-top: 10px; font-size: 14px; color: #721c24;">
|
| 191 |
+
CDS API configuration required
|
| 192 |
+
</p>
|
| 193 |
+
{% endif %}
|
| 194 |
+
</form>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<!-- {% if downloaded_files %}
|
| 199 |
+
<div class="container">
|
| 200 |
+
<h2>📦 Previously Downloaded Files</h2>
|
| 201 |
+
<div class="download-list">
|
| 202 |
+
{% for file in downloaded_files %}
|
| 203 |
+
<div class="download-item">
|
| 204 |
+
<div>
|
| 205 |
+
<strong>{{ file.date }}</strong>
|
| 206 |
+
<span style="color: #666;">({{ "%.1f"|format(file.size_mb) }} MB)</span>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
{% endfor %}
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
{% endif %} -->
|
| 213 |
+
|
| 214 |
+
<div class="method-section">
|
| 215 |
+
<h2>🗂️ Method 3: Choose from Recent Files</h2>
|
| 216 |
+
<p>Select a previously uploaded or downloaded file for visualization.</p>
|
| 217 |
+
<form id="recentFileForm" action="" method="get" style="display: flex; gap: 10px; align-items: center;">
|
| 218 |
+
<select name="recent_file" id="recent_file" required style="flex: 1;">
|
| 219 |
+
<option value="" disabled selected>Select a file...</option>
|
| 220 |
+
{% for file in recent_files %}
|
| 221 |
+
<option value="{{ file.name }}|{{ file.type }}">
|
| 222 |
+
{{ file.name }} ({{ 'Downloaded' if file.type == 'download' else 'Uploaded' }})
|
| 223 |
+
</option>
|
| 224 |
+
{% endfor %}
|
| 225 |
+
</select>
|
| 226 |
+
<button type="submit" class="btn">Analyze</button>
|
| 227 |
+
</form>
|
| 228 |
+
</div>
|
| 229 |
+
|
| 230 |
+
<div class="container">
|
| 231 |
+
<h2>📋 How to Use</h2>
|
| 232 |
+
<ol style="line-height: 1.8;">
|
| 233 |
+
<li><strong>Choose your method:</strong> Upload your own file or download data by date</li>
|
| 234 |
+
<li><strong>File Analysis:</strong> The system will detect air pollution variables in your data</li>
|
| 235 |
+
<li><strong>Variable Selection:</strong> Choose which pollutant to visualize and select pressure level (for atmospheric variables)</li>
|
| 236 |
+
<li><strong>Visualization:</strong> Generate and view the pollution map over India with your preferred color theme</li>
|
| 237 |
+
</ol>
|
| 238 |
+
|
| 239 |
+
<div class="info-box">
|
| 240 |
+
<strong>💡 Supported Variables:</strong>
|
| 241 |
+
PM2.5, PM10, PM1, NO₂, SO₂, O₃, CO, NO, NH₃, Total Column measurements, and more
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
|
| 245 |
+
<div class="cleanup-section">
|
| 246 |
+
<h3>🧹 Maintenance</h3>
|
| 247 |
+
<p>Clean up old files to free up disk space</p>
|
| 248 |
+
<a href="/cleanup" class="btn" style="font-size: 14px; padding: 8px 16px;">Clean Old Files</a>
|
| 249 |
+
</div>
|
| 250 |
+
</div>
|
| 251 |
+
|
| 252 |
+
<script>
|
| 253 |
+
document.getElementById('file').addEventListener('change', function() {
|
| 254 |
+
const file = this.files[0];
|
| 255 |
+
if (file) {
|
| 256 |
+
const maxSize = 500 * 1024 * 1024;
|
| 257 |
+
if (file.size > maxSize) {
|
| 258 |
+
alert('File size exceeds 500MB limit. Please choose a smaller file.');
|
| 259 |
+
this.value = '';
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
});
|
| 263 |
+
|
| 264 |
+
document.querySelector('form[action="/download_date"]').addEventListener('submit', function(e) {
|
| 265 |
+
if (!confirm('CAMS data download may take several minutes. Continue?')) {
|
| 266 |
+
e.preventDefault();
|
| 267 |
+
}
|
| 268 |
+
});
|
| 269 |
+
|
| 270 |
+
document.getElementById('recentFileForm').addEventListener('submit', function(e) {
|
| 271 |
+
e.preventDefault();
|
| 272 |
+
const val = document.getElementById('recent_file').value;
|
| 273 |
+
if (!val) return;
|
| 274 |
+
const [filename, filetype] = val.split('|');
|
| 275 |
+
// is_download param: true for download, false for upload
|
| 276 |
+
const is_download = (filetype === 'download') ? 'true' : 'false';
|
| 277 |
+
window.location.href = `/analyze/${encodeURIComponent(filename)}?is_download=${is_download}`;
|
| 278 |
+
});
|
| 279 |
+
</script>
|
| 280 |
+
</body>
|
| 281 |
+
</html>
|
templates/plot.html
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{{ plot_info.variable }} - CAMS Air Pollution Map</title>
|
| 7 |
+
<style>
|
| 8 |
+
body {
|
| 9 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 10 |
+
max-width: 1200px;
|
| 11 |
+
margin: 0 auto;
|
| 12 |
+
padding: 20px;
|
| 13 |
+
background: #f5f5f5;
|
| 14 |
+
}
|
| 15 |
+
.container {
|
| 16 |
+
background: white;
|
| 17 |
+
padding: 30px;
|
| 18 |
+
border-radius: 10px;
|
| 19 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
| 20 |
+
margin-bottom: 20px;
|
| 21 |
+
}
|
| 22 |
+
h1 { color: #2c3e50; text-align: center; margin-bottom: 30px; }
|
| 23 |
+
h2 { color: #34495e; border-bottom: 2px solid #3498db; padding-bottom: 10px; }
|
| 24 |
+
.info-section, .plot-section, .action-section, .technical-section {
|
| 25 |
+
background: #f8f9fa;
|
| 26 |
+
padding: 20px;
|
| 27 |
+
border-radius: 8px;
|
| 28 |
+
margin-bottom: 20px;
|
| 29 |
+
border-left: 4px solid #3498db;
|
| 30 |
+
}
|
| 31 |
+
.form-group {
|
| 32 |
+
margin-bottom: 15px;
|
| 33 |
+
}
|
| 34 |
+
.btn {
|
| 35 |
+
background: #3498db;
|
| 36 |
+
color: white;
|
| 37 |
+
padding: 12px 24px;
|
| 38 |
+
border: none;
|
| 39 |
+
border-radius: 5px;
|
| 40 |
+
cursor: pointer;
|
| 41 |
+
font-size: 16px;
|
| 42 |
+
font-weight: 600;
|
| 43 |
+
transition: background 0.3s;
|
| 44 |
+
text-decoration: none;
|
| 45 |
+
display: inline-block;
|
| 46 |
+
}
|
| 47 |
+
.btn:hover { background: #2980b9; }
|
| 48 |
+
.btn:disabled {
|
| 49 |
+
background: #bdc3c7;
|
| 50 |
+
cursor: not-allowed;
|
| 51 |
+
}
|
| 52 |
+
.btn-secondary {
|
| 53 |
+
background: #6c757d;
|
| 54 |
+
}
|
| 55 |
+
.btn-secondary:hover {
|
| 56 |
+
background: #5a6268;
|
| 57 |
+
}
|
| 58 |
+
.btn-download {
|
| 59 |
+
background: #28a745;
|
| 60 |
+
}
|
| 61 |
+
.btn-download:hover {
|
| 62 |
+
background: #218838;
|
| 63 |
+
}
|
| 64 |
+
.alert {
|
| 65 |
+
padding: 15px;
|
| 66 |
+
margin-bottom: 20px;
|
| 67 |
+
border-radius: 5px;
|
| 68 |
+
font-weight: 500;
|
| 69 |
+
}
|
| 70 |
+
.alert-success { background: #d4edda; border: 1px solid #c3e6cb; color: #155724; }
|
| 71 |
+
.alert-error { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; }
|
| 72 |
+
.alert-warning { background: #fff3cd; border: 1px solid #ffeaa7; color: #856404; }
|
| 73 |
+
.breadcrumb {
|
| 74 |
+
margin-bottom: 20px;
|
| 75 |
+
font-size: 14px;
|
| 76 |
+
color: #7f8c8d;
|
| 77 |
+
}
|
| 78 |
+
.breadcrumb a {
|
| 79 |
+
color: #3498db;
|
| 80 |
+
text-decoration: none;
|
| 81 |
+
}
|
| 82 |
+
.breadcrumb a:hover {
|
| 83 |
+
text-decoration: underline;
|
| 84 |
+
}
|
| 85 |
+
.two-column {
|
| 86 |
+
display: grid;
|
| 87 |
+
grid-template-columns: 1fr 1fr;
|
| 88 |
+
gap: 20px;
|
| 89 |
+
}
|
| 90 |
+
.plot-container {
|
| 91 |
+
text-align: center;
|
| 92 |
+
padding: 10px;
|
| 93 |
+
background: white;
|
| 94 |
+
border-radius: 8px;
|
| 95 |
+
box-shadow: 0 1px 5px rgba(0,0,0,0.1);
|
| 96 |
+
}
|
| 97 |
+
.plot-image {
|
| 98 |
+
max-width: 100%;
|
| 99 |
+
height: auto;
|
| 100 |
+
border-radius: 5px;
|
| 101 |
+
}
|
| 102 |
+
.plot-controls {
|
| 103 |
+
margin-top: 20px;
|
| 104 |
+
display: flex;
|
| 105 |
+
justify-content: center;
|
| 106 |
+
gap: 10px;
|
| 107 |
+
}
|
| 108 |
+
.info-grid, .stats-grid, .technical-grid {
|
| 109 |
+
display: grid;
|
| 110 |
+
gap: 10px;
|
| 111 |
+
}
|
| 112 |
+
.info-grid {
|
| 113 |
+
grid-template-columns: 1fr;
|
| 114 |
+
}
|
| 115 |
+
.info-item {
|
| 116 |
+
padding: 8px 0;
|
| 117 |
+
border-bottom: 1px solid #e1e1e1;
|
| 118 |
+
}
|
| 119 |
+
.info-item:last-child {
|
| 120 |
+
border-bottom: none;
|
| 121 |
+
}
|
| 122 |
+
.stats-grid {
|
| 123 |
+
grid-template-columns: repeat(3, 1fr);
|
| 124 |
+
text-align: center;
|
| 125 |
+
}
|
| 126 |
+
.stat-item {
|
| 127 |
+
background: white;
|
| 128 |
+
padding: 15px;
|
| 129 |
+
border-radius: 8px;
|
| 130 |
+
box-shadow: 0 1px 5px rgba(0,0,0,0.05);
|
| 131 |
+
}
|
| 132 |
+
.stat-value {
|
| 133 |
+
font-size: 24px;
|
| 134 |
+
font-weight: 700;
|
| 135 |
+
color: #3498db;
|
| 136 |
+
}
|
| 137 |
+
.stat-label {
|
| 138 |
+
font-size: 14px;
|
| 139 |
+
color: #7f8c8d;
|
| 140 |
+
margin-top: 5px;
|
| 141 |
+
}
|
| 142 |
+
.units-note {
|
| 143 |
+
text-align: center;
|
| 144 |
+
margin-top: 15px;
|
| 145 |
+
font-size: 14px;
|
| 146 |
+
color: #7f8c8d;
|
| 147 |
+
}
|
| 148 |
+
.action-section .button-group {
|
| 149 |
+
display: flex;
|
| 150 |
+
justify-content: center;
|
| 151 |
+
gap: 15px;
|
| 152 |
+
flex-wrap: wrap;
|
| 153 |
+
}
|
| 154 |
+
.technical-section summary {
|
| 155 |
+
cursor: pointer;
|
| 156 |
+
outline: none;
|
| 157 |
+
color: #34495e;
|
| 158 |
+
}
|
| 159 |
+
.technical-section h3 {
|
| 160 |
+
display: inline;
|
| 161 |
+
}
|
| 162 |
+
.technical-content {
|
| 163 |
+
padding-top: 10px;
|
| 164 |
+
}
|
| 165 |
+
.technical-grid {
|
| 166 |
+
grid-template-columns: 1fr 1fr;
|
| 167 |
+
gap: 20px;
|
| 168 |
+
}
|
| 169 |
+
.technical-grid ul {
|
| 170 |
+
padding-left: 20px;
|
| 171 |
+
margin-top: 5px;
|
| 172 |
+
list-style-type: '• ';
|
| 173 |
+
}
|
| 174 |
+
.fullscreen-modal {
|
| 175 |
+
display: none;
|
| 176 |
+
position: fixed;
|
| 177 |
+
z-index: 1000;
|
| 178 |
+
left: 0;
|
| 179 |
+
top: 0;
|
| 180 |
+
width: 100%;
|
| 181 |
+
height: 100%;
|
| 182 |
+
overflow: auto;
|
| 183 |
+
background-color: rgba(0, 0, 0, 0.9);
|
| 184 |
+
justify-content: center;
|
| 185 |
+
align-items: center;
|
| 186 |
+
}
|
| 187 |
+
.fullscreen-content {
|
| 188 |
+
position: relative;
|
| 189 |
+
max-width: 95%;
|
| 190 |
+
max-height: 95%;
|
| 191 |
+
}
|
| 192 |
+
.fullscreen-image {
|
| 193 |
+
max-width: 100%;
|
| 194 |
+
max-height: 100%;
|
| 195 |
+
}
|
| 196 |
+
.close-fullscreen {
|
| 197 |
+
position: absolute;
|
| 198 |
+
top: 20px;
|
| 199 |
+
right: 35px;
|
| 200 |
+
color: #f1f1f1;
|
| 201 |
+
font-size: 40px;
|
| 202 |
+
font-weight: bold;
|
| 203 |
+
transition: 0.3s;
|
| 204 |
+
background: none;
|
| 205 |
+
border: none;
|
| 206 |
+
cursor: pointer;
|
| 207 |
+
}
|
| 208 |
+
.close-fullscreen:hover, .close-fullscreen:focus {
|
| 209 |
+
color: #bbb;
|
| 210 |
+
text-decoration: none;
|
| 211 |
+
}
|
| 212 |
+
@media (max-width: 768px) {
|
| 213 |
+
.two-column, .technical-grid {
|
| 214 |
+
grid-template-columns: 1fr;
|
| 215 |
+
}
|
| 216 |
+
.container { padding: 20px; }
|
| 217 |
+
.action-section .button-group {
|
| 218 |
+
flex-direction: column;
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
</style>
|
| 222 |
+
</head>
|
| 223 |
+
<body>
|
| 224 |
+
<div class="container">
|
| 225 |
+
<h1>🗺️ {{ plot_info.variable }} Visualization</h1>
|
| 226 |
+
|
| 227 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 228 |
+
{% if messages %}
|
| 229 |
+
{% for category, message in messages %}
|
| 230 |
+
<div class="alert alert-{{ category }}">{{ message }}</div>
|
| 231 |
+
{% endfor %}
|
| 232 |
+
{% endif %}
|
| 233 |
+
{% endwith %}
|
| 234 |
+
|
| 235 |
+
<div class="breadcrumb">
|
| 236 |
+
<a href="{{ url_for('index') }}">🏠 Home</a> →
|
| 237 |
+
<a href="javascript:history.back()">Variable Selection</a> →
|
| 238 |
+
Visualization
|
| 239 |
+
</div>
|
| 240 |
+
|
| 241 |
+
<div class="plot-section">
|
| 242 |
+
<div class="plot-container">
|
| 243 |
+
<img src="{{ url_for('serve_plot', filename=plot_filename) }}"
|
| 244 |
+
alt="{{ plot_info.variable }} Map"
|
| 245 |
+
class="plot-image"
|
| 246 |
+
id="plotImage">
|
| 247 |
+
|
| 248 |
+
<div class="plot-controls">
|
| 249 |
+
<button onclick="downloadPlot()" class="btn btn-download">
|
| 250 |
+
💾 Download Image
|
| 251 |
+
</button>
|
| 252 |
+
<button onclick="toggleFullscreen()" class="btn btn-secondary">
|
| 253 |
+
🔍 View Fullscreen
|
| 254 |
+
</button>
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
|
| 259 |
+
<div class="two-column">
|
| 260 |
+
<div class="info-section">
|
| 261 |
+
<h2>📊 Variable Information</h2>
|
| 262 |
+
<div class="info-grid">
|
| 263 |
+
<div class="info-item">
|
| 264 |
+
<strong>Variable:</strong>
|
| 265 |
+
<span>{{ plot_info.variable }}</span>
|
| 266 |
+
</div>
|
| 267 |
+
<div class="info-item">
|
| 268 |
+
<strong>Units:</strong>
|
| 269 |
+
<span>{{ plot_info.units if plot_info.units else 'dimensionless' }}</span>
|
| 270 |
+
</div>
|
| 271 |
+
<div class="info-item">
|
| 272 |
+
<strong>Data Shape:</strong>
|
| 273 |
+
<span>{{ plot_info.shape }}</span>
|
| 274 |
+
</div>
|
| 275 |
+
{% if plot_info.pressure_level %}
|
| 276 |
+
<div class="info-item">
|
| 277 |
+
<strong>Pressure Level:</strong>
|
| 278 |
+
<span>{{ plot_info.pressure_level }} hPa</span>
|
| 279 |
+
</div>
|
| 280 |
+
{% endif %}
|
| 281 |
+
<div class="info-item">
|
| 282 |
+
<strong>Color Theme:</strong>
|
| 283 |
+
<span>{{ plot_info.color_theme }}</span>
|
| 284 |
+
</div>
|
| 285 |
+
<div class="info-item">
|
| 286 |
+
<strong>Generated:</strong>
|
| 287 |
+
<span>{{ plot_info.generated_time }}</span>
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
|
| 292 |
+
<div class="info-section">
|
| 293 |
+
<h2>📈 Data Statistics</h2>
|
| 294 |
+
<div class="stats-grid">
|
| 295 |
+
<div class="stat-item">
|
| 296 |
+
<div class="stat-value">{{ "%.3f"|format(plot_info.data_range.min) }}</div>
|
| 297 |
+
<div class="stat-label">Minimum</div>
|
| 298 |
+
</div>
|
| 299 |
+
<div class="stat-item">
|
| 300 |
+
<div class="stat-value">{{ "%.3f"|format(plot_info.data_range.max) }}</div>
|
| 301 |
+
<div class="stat-label">Maximum</div>
|
| 302 |
+
</div>
|
| 303 |
+
<div class="stat-item">
|
| 304 |
+
<div class="stat-value">{{ "%.3f"|format(plot_info.data_range.mean) }}</div>
|
| 305 |
+
<div class="stat-label">Average</div>
|
| 306 |
+
</div>
|
| 307 |
+
</div>
|
| 308 |
+
|
| 309 |
+
{% if plot_info.units %}
|
| 310 |
+
<p class="units-note">All values in {{ plot_info.units }}</p>
|
| 311 |
+
{% endif %}
|
| 312 |
+
</div>
|
| 313 |
+
</div>
|
| 314 |
+
|
| 315 |
+
<div class="action-section">
|
| 316 |
+
<h2>🛠️ Actions</h2>
|
| 317 |
+
<div class="button-group">
|
| 318 |
+
<a href="javascript:history.back()" class="btn btn-secondary">
|
| 319 |
+
← Create Another Visualization
|
| 320 |
+
</a>
|
| 321 |
+
<a href="{{ url_for('index') }}" class="btn btn-secondary">
|
| 322 |
+
🏠 Back to Home
|
| 323 |
+
</a>
|
| 324 |
+
<button onclick="sharePlot()" class="btn">
|
| 325 |
+
📤 Share Plot
|
| 326 |
+
</button>
|
| 327 |
+
</div>
|
| 328 |
+
</div>
|
| 329 |
+
|
| 330 |
+
<div class="technical-section">
|
| 331 |
+
<details>
|
| 332 |
+
<summary><h3>🔧 Technical Details</h3></summary>
|
| 333 |
+
<div class="technical-content">
|
| 334 |
+
<div class="technical-grid">
|
| 335 |
+
<div>
|
| 336 |
+
<strong>File Information:</strong>
|
| 337 |
+
<ul>
|
| 338 |
+
<li>Plot filename: {{ plot_filename }}</li>
|
| 339 |
+
<li>Generated: {{ plot_info.generated_time }}</li>
|
| 340 |
+
<li>Resolution: High (300 DPI)</li>
|
| 341 |
+
<li>Format: PNG</li>
|
| 342 |
+
</ul>
|
| 343 |
+
</div>
|
| 344 |
+
<div>
|
| 345 |
+
<strong>Map Details:</strong>
|
| 346 |
+
<ul>
|
| 347 |
+
<li>Projection: PlateCarree</li>
|
| 348 |
+
<li>Region: India (6°N-38°N, 68°E-98°E)</li>
|
| 349 |
+
<li>Features: Coastlines, borders, states</li>
|
| 350 |
+
<li>Gridlines: Enabled</li>
|
| 351 |
+
</ul>
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
</div>
|
| 355 |
+
</details>
|
| 356 |
+
</div>
|
| 357 |
+
</div>
|
| 358 |
+
|
| 359 |
+
<div id="fullscreenModal" class="fullscreen-modal">
|
| 360 |
+
<div class="fullscreen-content">
|
| 361 |
+
<button class="close-fullscreen" onclick="closeFullscreen()">×</button>
|
| 362 |
+
<img src="{{ url_for('serve_plot', filename=plot_filename) }}"
|
| 363 |
+
alt="{{ plot_info.variable }} Map"
|
| 364 |
+
class="fullscreen-image">
|
| 365 |
+
</div>
|
| 366 |
+
</div>
|
| 367 |
+
|
| 368 |
+
<script>
|
| 369 |
+
function downloadPlot() {
|
| 370 |
+
const link = document.createElement('a');
|
| 371 |
+
link.href = "{{ url_for('serve_plot', filename=plot_filename) }}";
|
| 372 |
+
link.download = '{{ plot_filename }}';
|
| 373 |
+
link.click();
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
function toggleFullscreen() {
|
| 377 |
+
document.getElementById('fullscreenModal').style.display = 'flex';
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
function closeFullscreen() {
|
| 381 |
+
document.getElementById('fullscreenModal').style.display = 'none';
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
function sharePlot() {
|
| 385 |
+
if (navigator.share) {
|
| 386 |
+
navigator.share({
|
| 387 |
+
title: '{{ plot_info.variable }} - Air Pollution Map',
|
| 388 |
+
text: 'Check out this air pollution visualization over India',
|
| 389 |
+
url: window.location.href
|
| 390 |
+
});
|
| 391 |
+
} else {
|
| 392 |
+
// Fallback: copy URL to clipboard
|
| 393 |
+
if (navigator.clipboard) {
|
| 394 |
+
navigator.clipboard.writeText(window.location.href).then(() => {
|
| 395 |
+
alert('URL copied to clipboard!');
|
| 396 |
+
});
|
| 397 |
+
} else {
|
| 398 |
+
alert('Share URL: ' + window.location.href);
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
// Keyboard shortcuts
|
| 404 |
+
document.addEventListener('keydown', function(e) {
|
| 405 |
+
if (e.key === 'Escape') {
|
| 406 |
+
closeFullscreen();
|
| 407 |
+
} else if (e.key === 'f' || e.key === 'F') {
|
| 408 |
+
toggleFullscreen();
|
| 409 |
+
} else if (e.ctrlKey && e.key === 's') {
|
| 410 |
+
e.preventDefault();
|
| 411 |
+
downloadPlot();
|
| 412 |
+
}
|
| 413 |
+
});
|
| 414 |
+
|
| 415 |
+
// Click outside to close fullscreen
|
| 416 |
+
document.getElementById('fullscreenModal').addEventListener('click', function(e) {
|
| 417 |
+
if (e.target === this) {
|
| 418 |
+
closeFullscreen();
|
| 419 |
+
}
|
| 420 |
+
});
|
| 421 |
+
</script>
|
| 422 |
+
</body>
|
| 423 |
+
</html>
|
templates/variables.html
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Variable Selection - CAMS Air Pollution</title>
|
| 7 |
+
<style>
|
| 8 |
+
body {
|
| 9 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 10 |
+
max-width: 1200px;
|
| 11 |
+
margin: 0 auto;
|
| 12 |
+
padding: 20px;
|
| 13 |
+
background: #f5f5f5;
|
| 14 |
+
}
|
| 15 |
+
.container {
|
| 16 |
+
background: white;
|
| 17 |
+
padding: 30px;
|
| 18 |
+
border-radius: 10px;
|
| 19 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
| 20 |
+
margin-bottom: 20px;
|
| 21 |
+
}
|
| 22 |
+
h1 { color: #2c3e50; text-align: center; margin-bottom: 30px; }
|
| 23 |
+
h2 { color: #34495e; border-bottom: 2px solid #3498db; padding-bottom: 10px; }
|
| 24 |
+
.method-section, .form-section, .pressure-section {
|
| 25 |
+
background: #f8f9fa;
|
| 26 |
+
padding: 20px;
|
| 27 |
+
border-radius: 8px;
|
| 28 |
+
margin-bottom: 20px;
|
| 29 |
+
border-left: 4px solid #3498db;
|
| 30 |
+
}
|
| 31 |
+
.form-group {
|
| 32 |
+
margin-bottom: 15px;
|
| 33 |
+
}
|
| 34 |
+
label {
|
| 35 |
+
display: block;
|
| 36 |
+
margin-bottom: 5px;
|
| 37 |
+
font-weight: 600;
|
| 38 |
+
color: #2c3e50;
|
| 39 |
+
}
|
| 40 |
+
input[type="file"], select, input[type="date"] {
|
| 41 |
+
width: 100%;
|
| 42 |
+
padding: 10px;
|
| 43 |
+
border: 2px solid #ddd;
|
| 44 |
+
border-radius: 5px;
|
| 45 |
+
font-size: 14px;
|
| 46 |
+
}
|
| 47 |
+
input[type="file"]:focus, select:focus, input[type="date"]:focus {
|
| 48 |
+
border-color: #3498db;
|
| 49 |
+
outline: none;
|
| 50 |
+
}
|
| 51 |
+
.btn {
|
| 52 |
+
background: #3498db;
|
| 53 |
+
color: white;
|
| 54 |
+
padding: 12px 24px;
|
| 55 |
+
border: none;
|
| 56 |
+
border-radius: 5px;
|
| 57 |
+
cursor: pointer;
|
| 58 |
+
font-size: 16px;
|
| 59 |
+
font-weight: 600;
|
| 60 |
+
transition: background 0.3s;
|
| 61 |
+
text-decoration: none;
|
| 62 |
+
display: inline-block;
|
| 63 |
+
}
|
| 64 |
+
.btn:hover { background: #2980b9; }
|
| 65 |
+
.btn:disabled {
|
| 66 |
+
background: #bdc3c7;
|
| 67 |
+
cursor: not-allowed;
|
| 68 |
+
}
|
| 69 |
+
.btn-secondary {
|
| 70 |
+
background: #6c757d;
|
| 71 |
+
}
|
| 72 |
+
.btn-secondary:hover {
|
| 73 |
+
background: #5a6268;
|
| 74 |
+
}
|
| 75 |
+
.alert {
|
| 76 |
+
padding: 15px;
|
| 77 |
+
margin-bottom: 20px;
|
| 78 |
+
border-radius: 5px;
|
| 79 |
+
font-weight: 500;
|
| 80 |
+
}
|
| 81 |
+
.alert-success { background: #d4edda; border: 1px solid #c3e6cb; color: #155724; }
|
| 82 |
+
.alert-error { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; }
|
| 83 |
+
.alert-warning { background: #fff3cd; border: 1px solid #ffeaa7; color: #856404; }
|
| 84 |
+
.info-box {
|
| 85 |
+
background: #e8f4fd;
|
| 86 |
+
border: 1px solid #bee5eb;
|
| 87 |
+
padding: 15px;
|
| 88 |
+
border-radius: 5px;
|
| 89 |
+
margin-bottom: 20px;
|
| 90 |
+
}
|
| 91 |
+
.status-indicator {
|
| 92 |
+
display: inline-block;
|
| 93 |
+
padding: 4px 8px;
|
| 94 |
+
border-radius: 3px;
|
| 95 |
+
font-size: 12px;
|
| 96 |
+
font-weight: bold;
|
| 97 |
+
margin-left: 10px;
|
| 98 |
+
}
|
| 99 |
+
.status-ready { background: #d4edda; color: #155724; }
|
| 100 |
+
.status-error { background: #f8d7da; color: #721c24; }
|
| 101 |
+
.file-info {
|
| 102 |
+
background: #f8f9fa;
|
| 103 |
+
padding: 10px;
|
| 104 |
+
border-radius: 5px;
|
| 105 |
+
margin-top: 10px;
|
| 106 |
+
font-size: 14px;
|
| 107 |
+
}
|
| 108 |
+
.breadcrumb {
|
| 109 |
+
margin-bottom: 20px;
|
| 110 |
+
font-size: 14px;
|
| 111 |
+
color: #7f8c8d;
|
| 112 |
+
}
|
| 113 |
+
.breadcrumb a {
|
| 114 |
+
color: #3498db;
|
| 115 |
+
text-decoration: none;
|
| 116 |
+
}
|
| 117 |
+
.breadcrumb a:hover {
|
| 118 |
+
text-decoration: underline;
|
| 119 |
+
}
|
| 120 |
+
.variable-info {
|
| 121 |
+
background: #ecf0f1;
|
| 122 |
+
padding: 15px;
|
| 123 |
+
border-radius: 8px;
|
| 124 |
+
margin-top: 15px;
|
| 125 |
+
}
|
| 126 |
+
.variable-info h4 {
|
| 127 |
+
margin-top: 0;
|
| 128 |
+
color: #34495e;
|
| 129 |
+
}
|
| 130 |
+
.info-text {
|
| 131 |
+
font-size: 14px;
|
| 132 |
+
color: #7f8c8d;
|
| 133 |
+
margin-top: 5px;
|
| 134 |
+
}
|
| 135 |
+
.color-preview-section {
|
| 136 |
+
margin-top: 15px;
|
| 137 |
+
}
|
| 138 |
+
.color-gradient {
|
| 139 |
+
width: 100%;
|
| 140 |
+
height: 20px;
|
| 141 |
+
border-radius: 5px;
|
| 142 |
+
border: 1px solid #ddd;
|
| 143 |
+
}
|
| 144 |
+
.button-group {
|
| 145 |
+
display: flex;
|
| 146 |
+
justify-content: space-between;
|
| 147 |
+
align-items: center;
|
| 148 |
+
}
|
| 149 |
+
.loading-message {
|
| 150 |
+
text-align: center;
|
| 151 |
+
margin-top: 20px;
|
| 152 |
+
color: #34495e;
|
| 153 |
+
}
|
| 154 |
+
.spinner {
|
| 155 |
+
border: 4px solid #f3f3f3;
|
| 156 |
+
border-top: 4px solid #3498db;
|
| 157 |
+
border-radius: 50%;
|
| 158 |
+
width: 40px;
|
| 159 |
+
height: 40px;
|
| 160 |
+
animation: spin 1s linear infinite;
|
| 161 |
+
margin: 10px auto;
|
| 162 |
+
}
|
| 163 |
+
@keyframes spin {
|
| 164 |
+
0% { transform: rotate(0deg); }
|
| 165 |
+
100% { transform: rotate(360deg); }
|
| 166 |
+
}
|
| 167 |
+
@media (max-width: 768px) {
|
| 168 |
+
.button-group {
|
| 169 |
+
flex-direction: column;
|
| 170 |
+
gap: 15px;
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
</style>
|
| 174 |
+
</head>
|
| 175 |
+
<body>
|
| 176 |
+
<div class="container">
|
| 177 |
+
<h1>🔬 Variable Selection</h1>
|
| 178 |
+
|
| 179 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 180 |
+
{% if messages %}
|
| 181 |
+
{% for category, message in messages %}
|
| 182 |
+
<div class="alert alert-{{ category }}">{{ message }}</div>
|
| 183 |
+
{% endfor %}
|
| 184 |
+
{% endif %}
|
| 185 |
+
{% endwith %}
|
| 186 |
+
|
| 187 |
+
<div class="breadcrumb">
|
| 188 |
+
<a href="{{ url_for('index') }}">🏠 Home</a> → Variable Selection
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
<form action="/visualize" method="post" id="visualizeForm">
|
| 192 |
+
<input type="hidden" name="filename" value="{{ filename }}">
|
| 193 |
+
<input type="hidden" name="is_download" value="{{ is_download }}">
|
| 194 |
+
|
| 195 |
+
<div class="form-section">
|
| 196 |
+
<h2>📊 Select Air Pollution Variable</h2>
|
| 197 |
+
<p>Found {{ variables|length }} air pollution variable(s) in your data. Select one below:</p>
|
| 198 |
+
|
| 199 |
+
<div class="form-group">
|
| 200 |
+
<label for="variable">Choose Variable:</label>
|
| 201 |
+
<select name="variable" id="variable" required onchange="handleVariableChange()">
|
| 202 |
+
<option value="">-- Select a variable --</option>
|
| 203 |
+
{% for var in variables %}
|
| 204 |
+
<option value="{{ var.name }}"
|
| 205 |
+
data-type="{{ var.type }}"
|
| 206 |
+
data-display="{{ var.display_name }}"
|
| 207 |
+
data-units="{{ var.units }}"
|
| 208 |
+
data-shape="{{ var.shape }}">
|
| 209 |
+
{{ var.display_name }} ({{ var.name }})
|
| 210 |
+
</option>
|
| 211 |
+
{% endfor %}
|
| 212 |
+
</select>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
<div class="variable-info" id="variableInfo" style="display: none;">
|
| 216 |
+
<h4>Selected Variable Details:</h4>
|
| 217 |
+
<div id="variableDetails"></div>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
<div class="pressure-section" id="pressureSection" style="display: none;">
|
| 222 |
+
<h3>🌡️ Pressure Level Selection</h3>
|
| 223 |
+
<p>This is an atmospheric variable. Please select a pressure level:</p>
|
| 224 |
+
|
| 225 |
+
<div class="form-group">
|
| 226 |
+
<label for="pressure_level">Pressure Level (hPa):</label>
|
| 227 |
+
<select name="pressure_level" id="pressure_level">
|
| 228 |
+
<option value="">-- Select pressure level --</option>
|
| 229 |
+
<option value="50">50 hPa (Stratosphere - ~20km)</option>
|
| 230 |
+
<option value="100">100 hPa (Tropopause - ~16km)</option>
|
| 231 |
+
<option value="150">150 hPa (Upper Troposphere - ~14km)</option>
|
| 232 |
+
<option value="200">200 hPa (Upper Troposphere - ~12km)</option>
|
| 233 |
+
<option value="250">250 hPa (Upper Troposphere - ~10km)</option>
|
| 234 |
+
<option value="300">300 hPa (Upper Troposphere - ~9km)</option>
|
| 235 |
+
<option value="400">400 hPa (Mid Troposphere - ~7km)</option>
|
| 236 |
+
<option value="500">500 hPa (Mid Troposphere - ~5km)</option>
|
| 237 |
+
<option value="600">600 hPa (Lower Troposphere - ~4km)</option>
|
| 238 |
+
<option value="700">700 hPa (Lower Troposphere - ~3km)</option>
|
| 239 |
+
<option value="850" selected>850 hPa (Lower Troposphere - ~1.5km)</option>
|
| 240 |
+
<option value="925">925 hPa (Boundary Layer - ~800m)</option>
|
| 241 |
+
<option value="1000">1000 hPa (Surface Level)</option>
|
| 242 |
+
</select>
|
| 243 |
+
<div class="info-text">
|
| 244 |
+
💡 <strong>Tip:</strong> 850 hPa is commonly used for atmospheric analysis (pre-selected)
|
| 245 |
+
</div>
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
|
| 249 |
+
<!-- Time Selection -->
|
| 250 |
+
<div class="form-section" id="timeSection" style="display: none;">
|
| 251 |
+
<h3>⏰ Time Selection</h3>
|
| 252 |
+
<p>Multiple time steps available. Please select a time:</p>
|
| 253 |
+
|
| 254 |
+
<div class="form-group">
|
| 255 |
+
<label for="time_index">Available Times:</label>
|
| 256 |
+
<select name="time_index" id="time_index">
|
| 257 |
+
<option value="">-- Loading available times --</option>
|
| 258 |
+
</select>
|
| 259 |
+
<div class="info-text">
|
| 260 |
+
💡 <strong>Tip:</strong> Latest time is usually pre-selected
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
<div class="form-section">
|
| 266 |
+
<h2>🎨 Color Theme</h2>
|
| 267 |
+
<div class="form-group">
|
| 268 |
+
<label for="color_theme">Select Color Scheme:</label>
|
| 269 |
+
<select name="color_theme" id="color_theme" onchange="updateColorPreview()">
|
| 270 |
+
{% for theme_key, theme_name in color_themes.items() %}
|
| 271 |
+
<option value="{{ theme_key }}"
|
| 272 |
+
{% if theme_key == 'viridis' %}selected{% endif %}>
|
| 273 |
+
{{ theme_name }}
|
| 274 |
+
</option>
|
| 275 |
+
{% endfor %}
|
| 276 |
+
</select>
|
| 277 |
+
</div>
|
| 278 |
+
|
| 279 |
+
<div class="color-preview-section">
|
| 280 |
+
<p><strong>Preview:</strong> <span id="colorPreviewText">Viridis</span></p>
|
| 281 |
+
<div class="color-gradient" id="colorPreview"></div>
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
|
| 285 |
+
<div class="form-section">
|
| 286 |
+
<div class="form-group">
|
| 287 |
+
<label style="display: block; margin-bottom: 10px; font-weight: 600;">
|
| 288 |
+
Choose Plot Type:
|
| 289 |
+
</label>
|
| 290 |
+
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
| 291 |
+
<button type="submit" formaction="{{ url_for('visualize') }}" class="btn">
|
| 292 |
+
📊 Generate Static Plot (PNG)
|
| 293 |
+
</button>
|
| 294 |
+
<button type="submit" formaction="{{ url_for('visualize_interactive') }}" class="btn btn-interactive">
|
| 295 |
+
🎯 Generate Interactive Plot (with hover info)
|
| 296 |
+
</button>
|
| 297 |
+
</div>
|
| 298 |
+
<p style="margin-top: 10px; font-size: 14px; color: #7f8c8d;">
|
| 299 |
+
<strong>Static:</strong> Fast PNG export for reports<br>
|
| 300 |
+
<strong>Interactive:</strong> Hover over any point to see exact values
|
| 301 |
+
</p>
|
| 302 |
+
</div>
|
| 303 |
+
|
| 304 |
+
<div class="loading-message" id="loadingMessage" style="display: none;">
|
| 305 |
+
<p>🔄 Generating map visualization... This may take a moment.</p>
|
| 306 |
+
<div class="spinner"></div>
|
| 307 |
+
</div>
|
| 308 |
+
</div>
|
| 309 |
+
</form>
|
| 310 |
+
</div>
|
| 311 |
+
|
| 312 |
+
<script>
|
| 313 |
+
function handleVariableChange() {
|
| 314 |
+
const select = document.getElementById('variable');
|
| 315 |
+
const selectedOption = select.options[select.selectedIndex];
|
| 316 |
+
|
| 317 |
+
if (selectedOption.value) {
|
| 318 |
+
// Show variable info
|
| 319 |
+
const info = document.getElementById('variableInfo');
|
| 320 |
+
const details = document.getElementById('variableDetails');
|
| 321 |
+
|
| 322 |
+
details.innerHTML = `
|
| 323 |
+
<strong>Name:</strong> ${selectedOption.dataset.display}<br>
|
| 324 |
+
<strong>Variable ID:</strong> ${selectedOption.value}<br>
|
| 325 |
+
<strong>Type:</strong> ${selectedOption.dataset.type}<br>
|
| 326 |
+
<strong>Units:</strong> ${selectedOption.dataset.units || 'dimensionless'}<br>
|
| 327 |
+
<strong>Shape:</strong> ${selectedOption.dataset.shape}
|
| 328 |
+
`;
|
| 329 |
+
info.style.display = 'block';
|
| 330 |
+
|
| 331 |
+
// Show/hide pressure level section
|
| 332 |
+
const pressureSection = document.getElementById('pressureSection');
|
| 333 |
+
if (selectedOption.dataset.type === 'atmospheric') {
|
| 334 |
+
pressureSection.style.display = 'block';
|
| 335 |
+
loadPressureLevels(selectedOption.value);
|
| 336 |
+
} else {
|
| 337 |
+
pressureSection.style.display = 'none';
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
// Always load available times
|
| 341 |
+
const timeSection = document.getElementById('timeSection');
|
| 342 |
+
timeSection.style.display = 'block';
|
| 343 |
+
loadAvailableTimes(selectedOption.value);
|
| 344 |
+
|
| 345 |
+
} else {
|
| 346 |
+
document.getElementById('variableInfo').style.display = 'none';
|
| 347 |
+
document.getElementById('pressureSection').style.display = 'none';
|
| 348 |
+
document.getElementById('timeSection').style.display = 'none';
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
function loadPressureLevels(varName) {
|
| 352 |
+
const pressureSelect = document.getElementById('pressure_level');
|
| 353 |
+
const originalHTML = pressureSelect.innerHTML;
|
| 354 |
+
|
| 355 |
+
pressureSelect.innerHTML = '<option value="">Loading pressure levels...</option>';
|
| 356 |
+
|
| 357 |
+
const filename = "{{ filename }}";
|
| 358 |
+
const isDownload = {{ 'true' if is_download else 'false' }};
|
| 359 |
+
|
| 360 |
+
fetch(`/get_pressure_levels/${filename}/${varName}?is_download=${isDownload}`)
|
| 361 |
+
.then(response => response.json())
|
| 362 |
+
.then(data => {
|
| 363 |
+
if (data.success && data.pressure_levels && data.pressure_levels.length > 0) {
|
| 364 |
+
pressureSelect.innerHTML = '<option value="">-- Select pressure level --</option>';
|
| 365 |
+
|
| 366 |
+
data.pressure_levels.forEach(level => {
|
| 367 |
+
const option = document.createElement('option');
|
| 368 |
+
option.value = level;
|
| 369 |
+
option.textContent = `${level} hPa`;
|
| 370 |
+
|
| 371 |
+
// Select 850 hPa as default
|
| 372 |
+
if (level == 850) {
|
| 373 |
+
option.selected = true;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
pressureSelect.appendChild(option);
|
| 377 |
+
});
|
| 378 |
+
} else {
|
| 379 |
+
console.log(data.error);
|
| 380 |
+
// Fallback to original options if API fails
|
| 381 |
+
pressureSelect.innerHTML = originalHTML;
|
| 382 |
+
}
|
| 383 |
+
})
|
| 384 |
+
.catch(error => {
|
| 385 |
+
console.log('Using default pressure levels');
|
| 386 |
+
pressureSelect.innerHTML = originalHTML;
|
| 387 |
+
});
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
function loadAvailableTimes(varName) {
|
| 391 |
+
const timeSelect = document.getElementById('time_index');
|
| 392 |
+
const originalHTML = timeSelect.innerHTML;
|
| 393 |
+
|
| 394 |
+
timeSelect.innerHTML = '<option value="">Loading available times...</option>';
|
| 395 |
+
|
| 396 |
+
const filename = "{{ filename }}";
|
| 397 |
+
const isDownload = {{ 'true' if is_download else 'false' }};
|
| 398 |
+
|
| 399 |
+
fetch(`/get_available_times/${filename}/${varName}?is_download=${isDownload}`)
|
| 400 |
+
.then(response => response.json())
|
| 401 |
+
.then(data => {
|
| 402 |
+
if (data.success && data.times && data.times.length > 0) {
|
| 403 |
+
timeSelect.innerHTML = '<option value="">-- Select time --</option>';
|
| 404 |
+
|
| 405 |
+
data.times.forEach(time => {
|
| 406 |
+
const option = document.createElement('option');
|
| 407 |
+
option.value = time.index;
|
| 408 |
+
option.textContent = time.display;
|
| 409 |
+
|
| 410 |
+
// Select latest time as default (last index)
|
| 411 |
+
if (time.index === data.times.length - 1) {
|
| 412 |
+
option.selected = true;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
timeSelect.appendChild(option);
|
| 416 |
+
});
|
| 417 |
+
} else {
|
| 418 |
+
console.log(data.error);
|
| 419 |
+
// Single time step or error
|
| 420 |
+
timeSelect.innerHTML = '<option value="0" selected>Latest Available</option>';
|
| 421 |
+
}
|
| 422 |
+
})
|
| 423 |
+
.catch(error => {
|
| 424 |
+
console.log('Using default time selection');
|
| 425 |
+
timeSelect.innerHTML = '<option value="0" selected>Latest Available</option>';
|
| 426 |
+
});
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
// Color theme preview
|
| 430 |
+
const colorMaps = {
|
| 431 |
+
'viridis': 'linear-gradient(to right, #440154, #414487, #2a788e, #22a884, #7ad151, #fde725)',
|
| 432 |
+
'plasma': 'linear-gradient(to right, #0d0887, #6a00a8, #b12a90, #e16462, #fca636, #f0f921)',
|
| 433 |
+
'YlOrRd': 'linear-gradient(to right, #ffffcc, #ffeda0, #fed976, #feb24c, #fd8d3c, #e31a1c)',
|
| 434 |
+
'Blues': 'linear-gradient(to right, #f7fbff, #deebf7, #c6dbef, #9ecae1, #6baed6, #2171b5)',
|
| 435 |
+
'Reds': 'linear-gradient(to right, #fff5f0, #fee0d2, #fcbba1, #fc9272, #fb6a4a, #de2d26)',
|
| 436 |
+
'Greens': 'linear-gradient(to right, #f7fcf5, #e5f5e0, #c7e9c0, #a1d99b, #74c476, #238b45)',
|
| 437 |
+
'Oranges': 'linear-gradient(to right, #fff5eb, #fee6ce, #fdd0a2, #fdae6b, #fd8d3c, #d94701)',
|
| 438 |
+
'Purples': 'linear-gradient(to right, #fcfbfd, #efedf5, #dadaeb, #bcbddc, #9e9ac8, #756bb1)',
|
| 439 |
+
'inferno': 'linear-gradient(to right, #000004, #420a68, #932667, #dd513a, #fca50a, #fcffa4)',
|
| 440 |
+
'magma': 'linear-gradient(to right, #000004, #3b0f70, #8c2981, #de4968, #fe9f6d, #fcfdbf)',
|
| 441 |
+
'cividis': 'linear-gradient(to right, #00224e, #123570, #3b496c, #575d6d, #707173, #8a8678)',
|
| 442 |
+
'coolwarm': 'linear-gradient(to right, #3b4cc0, #688aef, #b7d4f1, #f7f7f7, #f4b2a6, #dc7176, #a50026)',
|
| 443 |
+
'RdYlBu': 'linear-gradient(to right, #a50026, #d73027, #f46d43, #fdae61, #fee090, #e0f3f8, #abd9e9, #74add1, #4575b4, #313695)',
|
| 444 |
+
'Spectral': 'linear-gradient(to right, #9e0142, #d53e4f, #f46d43, #fdae61, #fee08b, #e6f598, #abdda4, #66c2a5, #3288bd, #5e4fa2)'
|
| 445 |
+
};
|
| 446 |
+
|
| 447 |
+
function updateColorPreview() {
|
| 448 |
+
const theme = document.getElementById('color_theme').value;
|
| 449 |
+
const preview = document.getElementById('colorPreview');
|
| 450 |
+
const previewText = document.getElementById('colorPreviewText');
|
| 451 |
+
|
| 452 |
+
previewText.textContent = document.getElementById('color_theme').selectedOptions[0].text;
|
| 453 |
+
|
| 454 |
+
if (colorMaps[theme]) {
|
| 455 |
+
preview.style.background = colorMaps[theme];
|
| 456 |
+
} else {
|
| 457 |
+
preview.style.background = colorMaps['viridis'];
|
| 458 |
+
}
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
// Initialize color preview
|
| 462 |
+
updateColorPreview();
|
| 463 |
+
|
| 464 |
+
// Form submission with loading state
|
| 465 |
+
document.getElementById('visualizeForm').addEventListener('submit', function(e) {
|
| 466 |
+
const selectedVar = document.getElementById('variable').value;
|
| 467 |
+
if (!selectedVar) {
|
| 468 |
+
alert('Please select a variable first!');
|
| 469 |
+
e.preventDefault();
|
| 470 |
+
return;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
// Check if atmospheric variable has pressure level selected
|
| 474 |
+
const varType = document.getElementById('variable').selectedOptions[0].dataset.type;
|
| 475 |
+
if (varType === 'atmospheric') {
|
| 476 |
+
const pressureLevel = document.getElementById('pressure_level').value;
|
| 477 |
+
if (!pressureLevel) {
|
| 478 |
+
alert('Please select a pressure level for atmospheric variables!');
|
| 479 |
+
e.preventDefault();
|
| 480 |
+
return;
|
| 481 |
+
}
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
const timeIndex = document.getElementById('time_index').value;
|
| 485 |
+
if (!timeIndex) {
|
| 486 |
+
alert('Please select a time step!');
|
| 487 |
+
e.preventDefault();
|
| 488 |
+
return;
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
// Show loading message
|
| 492 |
+
document.getElementById('loadingMessage').style.display = 'block';
|
| 493 |
+
document.getElementById('submitBtn').disabled = true;
|
| 494 |
+
document.getElementById('submitBtn').textContent = '⏳ Generating...';
|
| 495 |
+
});
|
| 496 |
+
|
| 497 |
+
// Auto-select first variable if only one available
|
| 498 |
+
{% if variables|length == 1 %}
|
| 499 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 500 |
+
setTimeout(() => {
|
| 501 |
+
document.getElementById('variable').selectedIndex = 1;
|
| 502 |
+
handleVariableChange();
|
| 503 |
+
}, 100);
|
| 504 |
+
});
|
| 505 |
+
{% endif %}
|
| 506 |
+
</script>
|
| 507 |
+
</body>
|
| 508 |
+
</html>
|