aditya-me13 commited on
Commit
cfea739
·
1 Parent(s): 3b42d8f

init commit

Browse files
.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()">&times;</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>