SmartCane/python_app/python_scripts/generate_interactive_ci_dashboard.py

1429 lines
60 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

r"""
Generate Interactive CI Dashboard for SmartCane ESA Project
This script creates an interactive HTML dashboard with Folium/Leaflet showing:
1. Current RGB composite map
2. Current CI (Chlorophyll Index) map
3. Previous week CI map
4. Week-over-week CI change map
The dashboard supports layer toggling, zooming, panning, and hover tooltips.
Usage:
python generate_interactive_ci_dashboard.py [estate_name] [current_week] [output_dir]
Example:
cd "c:\Users\timon\Resilience BV\4020 SCane ESA DEMO - Documenten\General\4020 SCDEMO Team\4020 TechnicalData\WP3\smartcane_v2\smartcane" ; python python_scripts/generate_interactive_ci_dashboard.py esa --current-week 43 --previous-week 42 --output-dir output
"""
import os
import sys
import json
import argparse
from pathlib import Path
from datetime import datetime, timedelta
from typing import Optional, Tuple
import warnings
try:
import numpy as np
import rasterio
from rasterio.plot import show
from rasterio.features import rasterize
from rasterio.transform import from_bounds
import folium
from folium import plugins
import geopandas as gpd
from shapely.geometry import shape
import matplotlib.pyplot as plt
import matplotlib.cm as cm
from matplotlib.colors import Normalize
except ImportError as e:
print(f"Error: Required package not found. {e}")
print("Install required packages with:")
print(" pip install numpy rasterio folium geopandas matplotlib")
sys.exit(1)
warnings.filterwarnings('ignore')
class InteractiveCIDashboard:
"""Generate interactive CI analysis dashboard using Folium."""
def __init__(self, estate_name: str, data_dir: str, output_dir: str):
"""
Initialize dashboard generator.
Args:
estate_name: Name of estate (e.g., 'esa', 'aura', 'simba')
data_dir: Base directory containing weekly mosaic data
output_dir: Directory to save generated HTML dashboard
"""
self.estate_name = estate_name.lower()
self.data_dir = Path(data_dir)
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
# Data paths
self.weekly_mosaic_dir = self.data_dir / "weekly_mosaic"
# Try multiple paths for field boundaries (in order of preference)
self.field_boundaries_path = None
possible_paths = [
self.data_dir / "Data" / "pivot.geojson", # laravel_app/storage/app/esa/Data/pivot.geojson
self.data_dir / "pivot.geojson", # laravel_app/storage/app/esa/pivot.geojson
self.data_dir / ".." / ".." / "pivot.geojson", # Up from esa/weekly_mosaic to smartcane/
Path(__file__).parent.parent / "r_app" / "experiments" / "pivot.geojson", # r_app/experiments/pivot.geojson
Path(__file__).parent.parent.parent / "r_app" / "experiments" / "pivot.geojson", # One more level up
]
print(f" Looking for pivot.geojson...")
for path in possible_paths:
resolved = path.resolve()
print(f" Checking: {resolved}")
if resolved.exists():
self.field_boundaries_path = resolved
print(f" ✓ Found at: {resolved}")
break
if self.field_boundaries_path is None:
print(f" ⚠ WARNING: pivot.geojson not found in any expected location")
# Validate directories
if not self.weekly_mosaic_dir.exists():
raise FileNotFoundError(f"Weekly mosaic directory not found: {self.weekly_mosaic_dir}")
self.field_boundaries = None
self.ci_data = {}
self.rgb_data = {}
self.bounds = None
print(f"✓ Dashboard initialized for {self.estate_name}")
print(f" Data directory: {self.data_dir}")
print(f" Output directory: {self.output_dir}")
def load_field_boundaries(self) -> gpd.GeoDataFrame:
"""Load field boundaries from GeoJSON."""
if self.field_boundaries_path is None:
print(f"✗ Field boundaries path not set - file not found in initialization")
return None
if not self.field_boundaries_path.exists():
print(f"✗ Field boundaries file not found at {self.field_boundaries_path}")
return None
try:
print(f"Loading field boundaries from: {self.field_boundaries_path}")
gdf = gpd.read_file(str(self.field_boundaries_path))
print(f"✓ Loaded field boundaries: {len(gdf)} features")
print(f" CRS: {gdf.crs}")
print(f" Columns: {list(gdf.columns)}")
if len(gdf) > 0:
print(f" First feature: {gdf.iloc[0]['field']} / {gdf.iloc[0].get('sub_field', 'N/A')}")
return gdf
except Exception as e:
print(f"✗ Error loading field boundaries: {e}")
import traceback
traceback.print_exc()
return None
def find_week_file(self, week: int, year: int = 2025) -> Optional[Path]:
"""Find the weekly mosaic file for a given week."""
filename = f"week_{week}_{year}.tif"
filepath = self.weekly_mosaic_dir / filename
if filepath.exists():
return filepath
else:
print(f"⚠ Week file not found: {filename}")
return None
def load_raster_bands(self, filepath: Path, bands: list) -> dict:
"""
Load specific bands from a raster file.
Args:
filepath: Path to raster file
bands: List of band names to extract (e.g., ['Red', 'Green', 'Blue', 'CI'])
Returns:
Dictionary with band names as keys and numpy arrays as values
"""
try:
with rasterio.open(filepath) as src:
# Get band indices based on names
band_data = {}
# Try to get band names from rasterio
all_bands = [src.descriptions[i] if src.descriptions[i] else str(i+1)
for i in range(src.count)]
print(f" Available bands: {all_bands}")
for band_name in bands:
# Try to find band by name
try:
if band_name in all_bands:
idx = all_bands.index(band_name) + 1
else:
# Try by index if name not found
idx = int(band_name) if band_name.isdigit() else None
if idx is None:
print(f" ⚠ Band '{band_name}' not found, skipping")
continue
band_data[band_name] = src.read(idx)
except Exception as e:
print(f" ⚠ Error reading band '{band_name}': {e}")
# Store metadata
self.bounds = src.bounds
self.crs = src.crs
self.transform = src.transform
return band_data
except Exception as e:
print(f"✗ Error loading raster {filepath}: {e}")
return {}
def normalize_raster(self, data: np.ndarray, vmin: Optional[float] = None,
vmax: Optional[float] = None, mask_invalid: bool = True) -> np.ndarray:
"""
Normalize raster data to 0-255 range for visualization.
Args:
data: Numpy array
vmin: Minimum value for normalization
vmax: Maximum value for normalization
mask_invalid: If True, set invalid values to 0 (will be transparent in image)
Returns:
Normalized array (0-255)
"""
# Create mask for invalid values
invalid_mask = ~np.isfinite(data)
# Remove invalid values for statistics
valid_data = data[~invalid_mask]
if len(valid_data) == 0:
return np.zeros_like(data, dtype=np.uint8)
if vmin is None:
vmin = np.percentile(valid_data, 2)
if vmax is None:
vmax = np.percentile(valid_data, 98)
# Normalize
normalized = np.clip((data - vmin) / (vmax - vmin + 1e-8) * 255, 0, 255)
# Set invalid values to 0 (will be transparent when converted to RGBA)
if mask_invalid:
normalized[invalid_mask] = 0
return normalized.astype(np.uint8)
def create_rgb_composite(self, r: np.ndarray, g: np.ndarray, b: np.ndarray) -> np.ndarray:
"""Create RGB composite from individual bands with transparency for NA values."""
# Create mask for invalid values (NA values in any band)
invalid_mask = ~(np.isfinite(r) & np.isfinite(g) & np.isfinite(b))
# Normalize each band
r_norm = self.normalize_raster(r, vmin=10, vmax=150, mask_invalid=False)
g_norm = self.normalize_raster(g, vmin=10, vmax=130, mask_invalid=False)
b_norm = self.normalize_raster(b, vmin=3, vmax=100, mask_invalid=False)
# Stack bands and add alpha channel
rgb = np.dstack([r_norm, g_norm, b_norm])
# Create RGBA with transparency for NA values
rgba = np.dstack([rgb, np.ones((*rgb.shape[:2], 1), dtype=np.uint8) * 255])
rgba[invalid_mask, 3] = 0 # Set alpha to 0 (transparent) for NA values
return rgba.astype(np.uint8)
def raster_to_image(self, data: np.ndarray, colormap: str = 'viridis') -> np.ndarray:
"""
Convert single-band raster to RGBA using colormap with transparency for NA values.
Args:
data: Single-band numpy array
colormap: Matplotlib colormap name
Returns:
RGBA image (H x W x 4) with alpha channel for NA masking
"""
# Create mask for invalid values
invalid_mask = ~np.isfinite(data)
# Get statistics from valid data only
valid_data = data[~invalid_mask]
if len(valid_data) == 0:
return np.ones((*data.shape, 4), dtype=np.uint8) * 255
vmin = np.percentile(valid_data, 2)
vmax = np.percentile(valid_data, 98)
# Normalize to 0-1 range
normalized = np.clip((data - vmin) / (vmax - vmin + 1e-8), 0, 1)
# Apply colormap
cmap = cm.get_cmap(colormap)
colored = cmap(normalized)
# Convert to 8-bit RGBA
rgba = (colored * 255).astype(np.uint8)
# Set alpha to 0 (transparent) for NA values
rgba[invalid_mask, 3] = 0
return rgba
def create_raster_image_url(self, data: np.ndarray, colormap: str = 'viridis',
fmt: str = 'png') -> str:
"""
Convert numpy array to base64 encoded image URL for Folium overlay.
Handles RGBA with transparency for NA masking.
Args:
data: Raster data
colormap: Colormap name
fmt: Image format ('png', 'jpeg')
Returns:
Base64 encoded data URL
"""
import io
import base64
from PIL import Image
# Convert to RGBA
if data.ndim == 3:
if data.shape[2] == 4:
# Already RGBA
rgba_data = data
elif data.shape[2] == 3:
# RGB - add alpha channel
alpha = np.ones((*data.shape[:2], 1), dtype=np.uint8) * 255
rgba_data = np.dstack([data, alpha])
else:
# Single band - apply colormap
rgba_data = self.raster_to_image(data, colormap)
else:
# Single band - apply colormap
rgba_data = self.raster_to_image(data, colormap)
# Convert to PIL Image with RGBA mode
img = Image.fromarray(rgba_data, mode='RGBA')
# Encode to base64
buffer = io.BytesIO()
img.save(buffer, format='PNG') # Always use PNG for transparency support
buffer.seek(0)
img_base64 = base64.b64encode(buffer.read()).decode()
data_url = f"data:image/png;base64,{img_base64}"
return data_url
def load_ci_and_rgb(self, week: int, week_label: str = "current") -> bool:
"""
Load CI and RGB data for a given week.
Args:
week: Week number
week_label: Label for storing data
Returns:
True if successful, False otherwise
"""
filepath = self.find_week_file(week)
if filepath is None:
return False
print(f"Loading week {week} data...")
# Load all bands
bands_data = self.load_raster_bands(filepath, ['Red', 'Green', 'Blue', 'NIR', 'CI'])
if not bands_data:
return False
# Store RGB composite
if 'Red' in bands_data and 'Green' in bands_data and 'Blue' in bands_data:
self.rgb_data[week_label] = self.create_rgb_composite(
bands_data['Red'],
bands_data['Green'],
bands_data['Blue']
)
print(f" ✓ RGB composite created for {week_label}")
# Store CI data
if 'CI' in bands_data:
self.ci_data[week_label] = bands_data['CI']
print(f" ✓ CI data loaded for {week_label}")
return True
def create_base_map(self, center_lat: float = -26.75, center_lon: float = 31.78,
zoom_level: int = 14) -> folium.Map:
"""Create base Folium map with multiple tile options."""
map_obj = folium.Map(
location=[center_lat, center_lon],
zoom_start=zoom_level,
tiles=None # Don't add default tile
)
# Add multiple base layers
folium.TileLayer(
tiles='OpenStreetMap',
name='OpenStreetMap',
overlay=False,
control=True
).add_to(map_obj)
# Add Google Maps Satellite layer
folium.TileLayer(
tiles='https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
attr='Google Satellite',
name='Google Satellite',
overlay=False,
control=True
).add_to(map_obj)
# Expose Leaflet map instance for custom JS (Leaflet Draw)
map_obj.get_root().html.add_child(
folium.Element('''
<script>
var mapExposed = false;
function exposeMap() {
if (!mapExposed) {
var maps = document.querySelectorAll('.folium-map');
if (maps.length > 0) {
for (var i = 0; i < maps.length; i++) {
if (maps[i]._leaflet_map) {
window._leaflet_map = maps[i]._leaflet_map;
window._leaflet_map._container.id = 'main-map-' + i;
mapExposed = true;
console.log('✓ Map exposed to window._leaflet_map');
break;
}
}
}
}
}
// Try multiple times to expose map
setTimeout(exposeMap, 100);
setTimeout(exposeMap, 500);
setTimeout(exposeMap, 1000);
setTimeout(exposeMap, 2000);
setTimeout(exposeMap, 3000);
</script>
''')
)
return map_obj
def add_raster_layer(self, map_obj: folium.Map, data: np.ndarray,
bounds: Tuple, name: str, colormap: str = 'viridis',
opacity: float = 0.8) -> folium.Map:
"""
Add raster data as overlay layer on Folium map.
Args:
map_obj: Folium map object
data: Raster data (numpy array)
bounds: Raster bounds (west, south, east, north)
name: Layer name
colormap: Colormap for visualization
opacity: Layer opacity
Returns:
Updated map object
"""
try:
# Convert raster to image
if data.ndim == 3 and data.shape[2] == 3:
# Already RGB
image_url = self.create_raster_image_url(data, fmt='png')
else:
# Single band - apply colormap
image_url = self.create_raster_image_url(data, colormap=colormap, fmt='png')
# Add as image overlay
folium.raster_layers.ImageOverlay(
image=image_url,
bounds=[[bounds.bottom, bounds.left], [bounds.top, bounds.right]],
name=name,
opacity=opacity,
show=True
).add_to(map_obj)
print(f" ✓ Added layer: {name}")
return map_obj
except Exception as e:
print(f" ✗ Error adding raster layer '{name}': {e}")
return map_obj
def add_field_boundaries(self, map_obj: folium.Map, gdf: gpd.GeoDataFrame,
name: str = "Field Boundaries") -> folium.Map:
"""Add all field boundaries as a single toggle-able layer group with field labels on hover."""
try:
print(f" Creating field boundaries layer group with {len(gdf)} fields...")
# Create a feature group for all field boundaries
field_group = folium.FeatureGroup(name="Field Boundaries", show=True)
for idx, row in gdf.iterrows():
# Get field name
field_name = row.get('field', f"Field {idx}")
sub_field = row.get('sub_field', '')
label = f"{field_name} - {sub_field}" if sub_field else field_name
# Convert geometry to GeoJSON
geojson_data = json.loads(gpd.GeoSeries(row.geometry).to_json())
# Create style function with proper closure
def get_style(x, field_name=field_name):
return {
'color': '#333333',
'weight': 2,
'opacity': 0.8,
'fill': True,
'fillColor': '#ffffff',
'fillOpacity': 0.0, # Invisible fill, but makes hover area larger
'dashArray': '5, 5'
}
# Add field boundary to the feature group with better hover
folium.GeoJson(
geojson_data,
style_function=get_style,
highlight_function=lambda x: {
'fillColor': '#ffff00',
'fillOpacity': 0.1,
'weight': 3,
'color': '#ff6600'
},
tooltip=folium.Tooltip(label, sticky=False),
popup=folium.Popup(f'<b>{label}</b>', max_width=250)
).add_to(field_group)
# Add the feature group to the map
field_group.add_to(map_obj)
print(f" ✓ Added {len(gdf)} field boundaries with hover interaction in single layer group")
except Exception as e:
print(f" ✗ Error adding field boundaries: {e}")
import traceback
traceback.print_exc()
return map_obj
def calculate_ci_change(self, ci_current: np.ndarray, ci_previous: np.ndarray) -> np.ndarray:
"""
Calculate week-over-week CI change.
Only calculate change where BOTH current and previous have valid data.
"""
# Create mask for valid data in both weeks
valid_mask = np.isfinite(ci_current) & np.isfinite(ci_previous)
# Initialize result with NaN
change = np.full_like(ci_current, np.nan, dtype=np.float32)
# Calculate change only where both are valid
change[valid_mask] = ci_current[valid_mask] - ci_previous[valid_mask]
return change
def add_legend_and_descriptions(self, map_obj: folium.Map, current_week: int,
previous_week: int) -> folium.Map:
"""Add legend and layer descriptions to the map with collapsible sections."""
# Create legend HTML with collapsible sections
legend_html = '''
<style>
.legend-box {
position: fixed;
background-color: white;
border: 2px solid #666;
z-index: 9999;
border-radius: 5px;
font-family: Arial, sans-serif;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.legend-header {
background-color: #f5f5f5;
padding: 12px;
cursor: pointer;
user-select: none;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #ddd;
}
.legend-header:hover {
background-color: #efefef;
}
.legend-header h3 {
margin: 0;
font-size: 14px;
color: #333;
}
.legend-toggle {
font-size: 16px;
color: #666;
font-weight: bold;
}
.legend-content {
padding: 12px;
font-size: 12px;
max-height: 500px;
overflow-y: auto;
}
.legend-content.collapsed {
display: none;
}
.legend-section {
margin-bottom: 15px;
padding-bottom: 12px;
border-bottom: 1px solid #eee;
}
.legend-section:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.legend-section-title {
font-weight: bold;
color: #333;
margin-bottom: 6px;
}
.legend-section-text {
color: #666;
line-height: 1.4;
margin-bottom: 6px;
}
.color-bar {
display: flex;
height: 20px;
border-radius: 3px;
margin: 4px 0;
border: 1px solid #999;
overflow: hidden;
}
.color-segment {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: bold;
color: rgba(0,0,0,0.7);
}
.tabs {
display: flex;
border-bottom: 2px solid #ddd;
margin-bottom: 12px;
}
.tab-button {
padding: 8px 12px;
background-color: #f0f0f0;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: bold;
color: #666;
margin-right: 2px;
border-radius: 3px 3px 0 0;
}
.tab-button:hover {
background-color: #e0e0e0;
}
.tab-button.active {
background-color: #fff;
color: #333;
border-bottom: 2px solid #1a9850;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
<!-- Title and Description Box -->
<div class="legend-box" style="top: 10px; left: 10px; width: 420px;">
<div class="legend-header">
<h3>SmartCane Interactive CI Dashboard</h3>
<span class="legend-toggle" onclick="toggleBox('title-box')"></span>
</div>
<div id="title-box" class="legend-content">
<!-- Tabs -->
<div class="tabs">
<button class="tab-button active" onclick="switchTab(event, 'info-tab')">📊 Info</button>
<button class="tab-button" onclick="switchTab(event, 'nuances-tab')">⚙️ Data Nuances</button>
<button class="tab-button" onclick="switchTab(event, 'changes-tab')">📈 What Changes CI</button>
</div>
<!-- Info Tab -->
<div id="info-tab" class="tab-content active">
<div style="margin-bottom: 8px;">
<strong>Estate:</strong> ESA
</div>
<div style="margin-bottom: 8px;">
<strong>Week:</strong> %d vs %d
</div>
<div style="margin-bottom: 8px;">
<strong>Generated:</strong> %s
</div>
<div style="border-top: 1px solid #ddd; padding-top: 8px; margin-top: 8px; color: #666;">
<p style="margin: 0 0 6px 0;">This interactive map shows Chlorophyll Index (CI) data for sugarcane fields. CI is a vegetation index derived from satellite imagery that indicates plant health and vigor.</p>
<p style="margin: 0;">Use the <strong>layer selector (top right)</strong> to toggle between different map layers. Transparent areas = no data available.</p>
</div>
</div>
<!-- Data Nuances Tab -->
<div id="nuances-tab" class="tab-content">
<div class="legend-section-title">☁️ Cloud Coverage</div>
<div class="legend-section-text" style="margin-bottom: 10px;">
<p style="margin: 0 0 6px 0;"><strong>White areas or holes:</strong> These are clouds or cloud shadows that could not be reliably processed. Planet satellite imagery (optical) cannot see through clouds.</p>
<p style="margin: 0 0 6px 0;"><strong>Current filtering:</strong> Basic cloud filtering is applied, but some clouds may remain.</p>
<p style="margin: 0;"><strong>Future improvement:</strong> Advanced cloud detection (OmniCloudMask) will be integrated to improve data quality.</p>
</div>
<div class="legend-section-title" style="margin-top: 10px;">🔍 Data Quality Notes</div>
<div class="legend-section-text">
<p style="margin: 0 0 6px 0;">• <strong>Resolution:</strong> 3m pixel size (accurate to 3m × 3m area on ground)</p>
<p style="margin: 0 0 6px 0;">• <strong>Frequency:</strong> Weekly composites from Planet satellite data</p>
<p style="margin: 0 0 6px 0;">• <strong>Temporal lag:</strong> May be 1-2 days behind current date due to processing</p>
<p style="margin: 0;"><strong>• NA values:</strong> Fields outside boundary or areas with data gaps appear transparent</p>
</div>
</div>
<!-- What Changes CI Tab -->
<div id="changes-tab" class="tab-content">
<div class="legend-section-title">🌱 Factors That Increase CI</div>
<div class="legend-section-text" style="margin-bottom: 10px;">
<p style="margin: 0 0 4px 0;">✅ Normal crop growth (young to mature stage)</p>
<p style="margin: 0 0 4px 0;">✅ Adequate water availability (good irrigation)</p>
<p style="margin: 0 0 4px 0;">✅ Sufficient nutrient availability (N, P, K)</p>
<p style="margin: 0 0 4px 0;">✅ Favorable weather conditions</p>
<p style="margin: 0;✅ Canopy closure and full leaf development</p>
</div>
<div class="legend-section-title" style="margin-top: 10px;">🔴 Factors That Decrease CI</div>
<div class="legend-section-text" style="margin-bottom: 10px;">
<p style="margin: 0 0 4px 0;">❌ Drought stress or irrigation failure</p>
<p style="margin: 0 0 4px 0;">❌ Nutrient deficiency (especially nitrogen)</p>
<p style="margin: 0 0 4px 0;">❌ Disease or pest damage (rust, smut, borers)</p>
<p style="margin: 0 0 4px 0;">❌ Weed competition in young fields</p>
<p style="margin: 0 0 4px 0;">❌ Lodging (crop falling over)</p>
<p style="margin: 0;❌ Harvest operations (removing biomass)</p>
</div>
<div class="legend-section-title" style="margin-top: 10px;">⚡ Rapid Changes</div>
<div class="legend-section-text">
Large week-to-week changes may indicate: harvesting activity, major weather events, irrigation changes, or application of crop inputs. Always cross-check with field records.
</div>
</div>
</div>
</div>
<!-- Legend Box -->
<div class="legend-box" style="bottom: 10px; left: 10px; width: 380px;">
<div class="legend-header">
<h3>Legend & Interpretation Guide</h3>
<span class="legend-toggle" onclick="toggleBox('legend-box')"></span>
</div>
<div id="legend-box" class="legend-content">
<div class="legend-section">
<div class="legend-section-title">RGB Composite (Current Week)</div>
<div class="legend-section-text">
Natural color image showing actual field appearance. Green pixels = healthy vegetation, Brown/Red pixels = sparse vegetation or bare soil.
</div>
</div>
<div class="legend-section">
<div class="legend-section-title">Chlorophyll Index (CI) - Current Week</div>
<div class="legend-section-text">
Measures plant chlorophyll content (plant health indicator). Higher values = healthier, more vigorous plants.
</div>
<div class="color-bar">
<div class="color-segment" style="background-color: #440154; color: white;">Bare (0-2)</div>
<div class="color-segment" style="background-color: #31688e; color: white;">Low (2-4)</div>
<div class="color-segment" style="background-color: #35b779; color: white;">Good (4-6)</div>
<div class="color-segment" style="background-color: #fde724; color: #000;">Excellent (6+)</div>
</div>
</div>
<div class="legend-section">
<div class="legend-section-title">CI - Previous Week</div>
<div class="legend-section-text">
Last week's Chlorophyll Index using the same color scale. Compare with current week to identify growth trends.
</div>
<div class="color-bar">
<div class="color-segment" style="background-color: #440154; color: white;">Bare</div>
<div class="color-segment" style="background-color: #31688e; color: white;">Low</div>
<div class="color-segment" style="background-color: #35b779; color: white;">Good</div>
<div class="color-segment" style="background-color: #fde724; color: #000;">Excellent</div>
</div>
</div>
<div class="legend-section">
<div class="legend-section-title">📊 CI Change (Week-over-Week)</div>
<div class="legend-section-text">
Week-to-week difference in Chlorophyll Index. Shows where fields are improving or declining.
</div>
<div class="color-bar">
<div class="color-segment" style="background-color: #0d0887; color: white;">Large Decrease</div>
<div class="color-segment" style="background-color: #7e03a8; color: white;">Decrease</div>
<div class="color-segment" style="background-color: #cc4778; color: white;">Slight Change</div>
<div class="color-segment" style="background-color: #f89540; color: #000;">Increase</div>
<div class="color-segment" style="background-color: #fde724; color: #000;">Large Increase</div>
</div>
</div>
<div class="legend-section">
<div class="legend-section-title">Field Boundaries</div>
<div class="legend-section-text">
Field polygons outlined in dashed dark lines. Use layer control (top right) to toggle field labels on/off.
</div>
</div>
</div>
</div>
<script>
function toggleBox(boxId) {
const box = document.getElementById(boxId);
box.classList.toggle('collapsed');
// Update toggle symbol
const toggle = event.target;
if (box.classList.contains('collapsed')) {
toggle.textContent = '+';
} else {
toggle.textContent = '';
}
}
function switchTab(evt, tabName) {
// Hide all tab contents
var tabContents = document.getElementsByClassName('tab-content');
for (var i = 0; i < tabContents.length; i++) {
tabContents[i].classList.remove('active');
}
// Remove active class from all tab buttons
var tabButtons = document.getElementsByClassName('tab-button');
for (var i = 0; i < tabButtons.length; i++) {
tabButtons[i].classList.remove('active');
}
// Show the current tab and mark button as active
document.getElementById(tabName).classList.add('active');
evt.currentTarget.classList.add('active');
}
</script>
''' % (current_week, previous_week, datetime.now().strftime('%Y-%m-%d %H:%M'))
map_obj.get_root().html.add_child(folium.Element(legend_html))
# Inject Turf.js for spatial operations and expose field GeoJSON to JS (if available)
try:
if self.field_boundaries is not None:
# Add Turf.js library
map_obj.get_root().html.add_child(folium.Element("<script src='https://cdn.jsdelivr.net/npm/@turf/turf@6/turf.min.js'></script>"))
# Serialize field boundaries GeoJSON and inject as window.fieldGeoJSON
try:
fb_geojson = self.field_boundaries.to_json()
map_obj.get_root().html.add_child(folium.Element(f"<script>window.fieldGeoJSON = {fb_geojson};</script>"))
except Exception as e:
print('⚠ Could not serialize field boundaries to GeoJSON for client-side lookup:', e)
except Exception:
# Non-fatal: continue without field injection
pass
# --- Coordinate Extraction Module (collapsible box, click-to-place with comments) ---
coord_html = '''
<style>
.coord-box {
position: fixed;
background-color: white;
border: 2px solid #666;
z-index: 9999;
border-radius: 5px;
font-family: Arial, sans-serif;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
bottom: 10px;
right: 10px;
width: 360px;
padding: 12px;
}
.coord-title {
margin: 0 0 10px 0;
font-size: 14px;
color: #333;
font-weight: bold;
}
.coord-btn { margin: 4px 2px 4px 0; padding: 6px 10px; font-size: 11px; border-radius: 3px; border: 1px solid #aaa; background: #f0f0f0; cursor: pointer; font-weight: bold; }
.coord-btn:hover { background: #e0e0e0; }
.coord-btn.active { background: #4CAF50; color: white; border-color: #2d6b2f; }
.coord-input-group { margin: 8px 0; padding: 8px; background: #f9f9f9; border-radius: 3px; }
.coord-input-group label { display: block; margin-bottom: 4px; font-weight: bold; font-size: 11px; color: #333; }
.coord-input-group input { width: 100%; padding: 5px; font-size: 11px; border: 1px solid #ccc; border-radius: 3px; font-family: Arial; box-sizing: border-box; }
.coord-list { margin: 8px 0; padding: 8px; background: #f5f5f5; border-radius: 3px; font-size: 12px; max-height: 300px; overflow-y: auto; }
.coord-list h4 { margin: 0 0 6px 0; font-size: 12px; color: #333; }
.coord-list ul { padding: 0; list-style: none; margin: 0; }
.coord-list li { padding: 4px 0; font-size: 11px; color: #333; border-bottom: 1px solid #ddd; font-family: monospace; display: flex; justify-content: space-between; align-items: center; }
.coord-list li:last-child { border-bottom: none; }
.coord-list li strong { color: #1565c0; }
.coord-list li em { color: #d32f2f; font-style: italic; }
.coord-list li .coord-point-info { flex: 1; }
.coord-list li .coord-delete-btn { padding: 2px 6px; font-size: 10px; background: #ff6b6b; color: white; border: 1px solid #c92a2a; border-radius: 3px; cursor: pointer; margin-left: 8px; }
.coord-list li .coord-delete-btn:hover { background: #ff5252; }
</style>
<div class="coord-box">
<div class="coord-title">📍 Coordinate Extraction</div>
<div style="margin-bottom: 10px; padding: 8px; background: #e3f2fd; border-left: 3px solid #2196F3; font-size: 11px; color: #1565c0; border-radius: 2px;">
<strong>💡 Tip:</strong> Turn off "Field Boundaries" layer (top right) for easier point placement. Click "Place Points" to activate, then click on map areas you want to inspect. Export coordinates to send people to these locations.
</div>
<button class="coord-btn" id="coord-toggleDrawBtn" onclick="window.coordToggleDraw()">
Place Points
</button>
<button class="coord-btn" onclick="window.coordExportGeoJSON()">
↓ GeoJSON
</button>
<button class="coord-btn" onclick="window.coordExportKML()">
↓ KML
</button>
<button class="coord-btn" onclick="window.coordClearPoints()" style="background: #ff6b6b; color: white; border-color: #c92a2a;">
🗑️ Clear
</button>
<div class="coord-input-group">
<label for="coord-field">Field name (optional):</label>
<input type="text" id="coord-field" placeholder="e.g., KHWC, 00F28..." />
</div>
<div class="coord-input-group">
<label for="coord-comment">Comment (optional):</label>
<input type="text" id="coord-comment" placeholder="e.g., weeds, irrigation issue, disease..." />
</div>
<div class="coord-list">
<h4>Points: <span id="coord-pointCount">0</span></h4>
<ul id="coord-points-list"></ul>
</div>
</div>
'''
# Inject script first (separate from HTML)
map_obj.get_root().html.add_child(folium.Element('''
<script>
// Initialize coordinate extraction functions
window.coordPoints = [];
window.coordDrawingMode = false;
window.coordMarkerGroup = null;
window.coordMapClickHandler = null;
// Function to find and setup the Leaflet map
window.coordFindMap = function() {
// Try multiple methods to find the map
var map = null;
// Method 1: Check window._leaflet_map (exposed earlier)
if (window._leaflet_map) {
map = window._leaflet_map;
console.log('✓ Found map via window._leaflet_map');
return map;
}
// Method 2: Search all window properties for map variables (Folium pattern: map_xxxxx)
if (!map) {
for (var prop in window) {
if (prop.startsWith('map_') && window[prop] && window[prop]._container) {
map = window[prop];
window._leaflet_map = map; // Cache it for future use
console.log('✓ Found map via window.' + prop);
return map;
}
}
}
// Method 3: Check all elements with .folium-map class
if (!map) {
var mapContainers = document.querySelectorAll('.folium-map');
for (var i = 0; i < mapContainers.length; i++) {
if (mapContainers[i]._leaflet_id) {
// Iterate through all Leaflet layers to find the map
for (var leafletId in L._layers) {
var layer = L._layers[leafletId];
if (layer._container === mapContainers[i]) {
map = layer;
window._leaflet_map = map; // Cache it
console.log('✓ Found map via L._layers');
return map;
}
}
}
}
}
return map;
};
// Setup map click handler for point placement
window.coordSetupClickHandler = function(map) {
if (!map || window.coordMapClickHandler) return false;
// Create marker group if needed
if (!window.coordMarkerGroup) {
window.coordMarkerGroup = L.featureGroup().addTo(map);
console.log('✓ Created marker group');
}
// Initialize markers array to track marker references
if (!window.coordMarkers) {
window.coordMarkers = [];
}
// Setup click handler
window.coordMapClickHandler = function(e) {
if (!window.coordDrawingMode) return;
var latlng = e.latlng;
var fieldName = document.getElementById('coord-field').value || '';
var comment = document.getElementById('coord-comment').value || '';
// Add to points array (include field name if provided)
var pointIndex = window.coordPoints.length;
var ptObj = {lat: latlng.lat, lng: latlng.lng, comment: comment};
if (fieldName) ptObj.field = fieldName;
window.coordPoints.push(ptObj);
// Create marker with popup including field name
var marker = L.marker([latlng.lat, latlng.lng], { draggable: true });
var idx = pointIndex + 1;
var popupText = '<b>Point ' + idx + '</b><br>Lat: ' + latlng.lat.toFixed(6) + '<br>Lng: ' + latlng.lng.toFixed(6);
if (fieldName) popupText += '<br><strong>🏷️ Field:</strong> ' + fieldName;
if (comment) popupText += '<br><strong>📝 ' + comment + '</strong>';
marker.bindPopup(popupText);
marker.addTo(window.coordMarkerGroup);
// Store marker reference
window.coordMarkers[pointIndex] = marker;
// Clear input fields
document.getElementById('coord-field').value = '';
document.getElementById('coord-comment').value = '';
// Update UI
window.coordUpdatePointsList();
};
map.on('click', window.coordMapClickHandler);
console.log('✓ Map click handler setup complete');
return true;
};
// Toggle drawing mode
window.coordToggleDraw = function() {
// Try to find map
var map = window.coordFindMap();
if (!map) {
alert('Map is still loading. Please wait a moment and try again.');
console.log('✗ Map not found yet');
return;
}
// Setup click handler if not already done
if (!window.coordMapClickHandler) {
window.coordSetupClickHandler(map);
}
// Toggle mode
window.coordDrawingMode = !window.coordDrawingMode;
var btn = document.getElementById('coord-toggleDrawBtn');
if (window.coordDrawingMode) {
btn.classList.add('active');
btn.textContent = '⏸️ Stop Placing';
console.log('✓ Drawing mode ON');
} else {
btn.classList.remove('active');
btn.textContent = ' Place Points';
console.log('✓ Drawing mode OFF');
}
// Update point count
document.getElementById('coord-pointCount').textContent = window.coordPoints.length;
};
// Delete a specific point
window.coordDeletePoint = function(index) {
if (index < 0 || index >= window.coordPoints.length) return;
// Remove marker from map
if (window.coordMarkers && window.coordMarkers[index]) {
window.coordMarkerGroup.removeLayer(window.coordMarkers[index]);
}
// Remove from arrays
window.coordPoints.splice(index, 1);
window.coordMarkers.splice(index, 1);
// Update UI
window.coordUpdatePointsList();
console.log('✓ Deleted point ' + (index + 1));
};
// Update points list UI
window.coordUpdatePointsList = function() {
var list = document.getElementById('coord-points-list');
var count = document.getElementById('coord-pointCount');
if (!list || !count) return;
list.innerHTML = '';
count.textContent = window.coordPoints.length;
window.coordPoints.forEach(function(pt, i) {
var li = document.createElement('li');
var fieldText = pt.field ? ' <strong style="color: #1976d2;">[' + pt.field + ']</strong>' : '';
var commentText = pt.comment ? ' <em>' + pt.comment + '</em>' : '';
var infoSpan = document.createElement('span');
infoSpan.className = 'coord-point-info';
infoSpan.innerHTML = '<strong>P' + (i+1) + ':</strong> ' +
pt.lat.toFixed(6) + ', ' + pt.lng.toFixed(6) + fieldText + commentText;
var deleteBtn = document.createElement('button');
deleteBtn.className = 'coord-delete-btn';
deleteBtn.textContent = '';
deleteBtn.title = 'Delete this point';
deleteBtn.onclick = (function(idx) {
return function() { window.coordDeletePoint(idx); };
})(i);
li.appendChild(infoSpan);
li.appendChild(deleteBtn);
list.appendChild(li);
});
if (window.coordPoints.length === 0) {
var li = document.createElement('li');
li.style.color = '#999';
li.style.justifyContent = 'center';
li.innerHTML = 'No points yet';
list.appendChild(li);
}
};
// Export to GeoJSON
window.coordExportGeoJSON = function() {
if (window.coordPoints.length === 0) {
alert('No points to export');
return;
}
var geojson = {
type: 'FeatureCollection',
features: window.coordPoints.map(function(pt, i) {
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [pt.lng, pt.lat]
},
properties: {
name: 'Point ' + (i+1),
description: 'Lat: ' + pt.lat.toFixed(6) + ', Lng: ' + pt.lng.toFixed(6),
comment: pt.comment || '',
field: pt.field || ''
}
};
})
};
var blob = new Blob([JSON.stringify(geojson, null, 2)], {type: 'application/json'});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'field_points_' + new Date().toISOString().split('T')[0] + '.geojson';
a.click();
setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
console.log('✓ Exported ' + window.coordPoints.length + ' points to GeoJSON');
};
// Export to KML
window.coordExportKML = function() {
if (window.coordPoints.length === 0) {
alert('No points to export');
return;
}
var kml = '<?xml version="1.0" encoding="UTF-8"?>\\n' +
'<kml xmlns="http://www.opengis.net/kml/2.2">\\n' +
'<Document>\\n<name>Field Points</name>\\n';
window.coordPoints.forEach(function(pt, i) {
kml += '<Placemark>\\n<name>Point ' + (i+1) + '</name>\\n';
// Include field name and comment in description when present
var descParts = [];
if (pt.field) descParts.push('Field: ' + pt.field);
if (pt.comment) descParts.push(pt.comment);
if (descParts.length > 0) {
kml += '<description>' + descParts.join(' - ') + '</description>\\n';
}
kml += '<Point>\\n<coordinates>' + pt.lng + ',' + pt.lat + ',0</coordinates>\\n' +
'</Point>\\n</Placemark>\\n';
});
kml += '</Document>\\n</kml>';
var blob = new Blob([kml], {type: 'application/vnd.google-earth.kml+xml'});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'field_points_' + new Date().toISOString().split('T')[0] + '.kml';
a.click();
setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
console.log('✓ Exported ' + window.coordPoints.length + ' points to KML');
};
// Clear all points
window.coordClearPoints = function() {
if (window.coordPoints.length === 0) {
alert('No points to clear');
return;
}
if (confirm('Delete all ' + window.coordPoints.length + ' points?')) {
window.coordPoints = [];
window.coordMarkers = [];
if (window.coordMarkerGroup) {
window.coordMarkerGroup.clearLayers();
}
window.coordUpdatePointsList();
console.log('✓ Cleared all points');
}
};
// Initialize UI when DOM is ready
window.coordUpdatePointsList();
console.log('✓ Coordinate extraction module loaded');
</script>
'''))
# Then add the HTML element
map_obj.get_root().html.add_child(folium.Element(coord_html))
def generate_dashboard(self, current_week: int, previous_week: Optional[int] = None) -> str:
"""
Generate complete interactive dashboard.
Args:
current_week: Current week number
previous_week: Previous week number (default: current_week - 1)
Returns:
Path to generated HTML file
"""
if previous_week is None:
previous_week = current_week - 1
print(f"\n{'='*60}")
print(f"Generating Interactive CI Dashboard")
print(f"Estate: {self.estate_name}")
print(f"Current week: {current_week}, Previous week: {previous_week}")
print(f"{'='*60}\n")
# Load field boundaries
self.field_boundaries = self.load_field_boundaries()
# Load current week data
if not self.load_ci_and_rgb(current_week, "current"):
print(f"✗ Failed to load data for week {current_week}")
return None
# Load previous week data for comparison
if not self.load_ci_and_rgb(previous_week, "previous"):
print(f"⚠ Warning: Could not load data for week {previous_week}")
# Create base map
print("\nCreating map...")
map_obj = self.create_base_map()
# Add current RGB layer
if 'current' in self.rgb_data:
self.add_raster_layer(
map_obj,
self.rgb_data['current'],
self.bounds,
name="RGB Composite (Current Week)",
opacity=1.0
)
# Add current CI layer
if 'current' in self.ci_data:
self.add_raster_layer(
map_obj,
self.ci_data['current'],
self.bounds,
name="CI - Current Week (Week {})".format(current_week),
colormap='viridis',
opacity=1.0
)
# Add previous week CI layer
if 'previous' in self.ci_data:
self.add_raster_layer(
map_obj,
self.ci_data['previous'],
self.bounds,
name="CI - Previous Week (Week {})".format(previous_week),
colormap='viridis',
opacity=1.0
)
# Add CI change layer - using Plasma colormap
if 'current' in self.ci_data and 'previous' in self.ci_data:
ci_change = self.calculate_ci_change(
self.ci_data['current'],
self.ci_data['previous']
)
self.add_raster_layer(
map_obj,
ci_change,
self.bounds,
name="CI Change (Current - Previous)",
colormap='plasma', # Plasma for change visualization
opacity=1.0
)
# Add field boundaries
if self.field_boundaries is not None:
self.add_field_boundaries(map_obj, self.field_boundaries)
# Add layer control
folium.LayerControl(position='topright', collapsed=False).add_to(map_obj)
# Add legend and descriptions (includes title box)
self.add_legend_and_descriptions(map_obj, current_week, previous_week)
# Save map
output_file = self.output_dir / f"ci_dashboard_{self.estate_name}_w{current_week}.html"
map_obj.save(str(output_file))
print(f"\n{'='*60}")
print(f"✓ Dashboard generated successfully!")
print(f" Output: {output_file}")
print(f"{'='*60}\n")
return str(output_file)
def main():
"""Command-line interface."""
parser = argparse.ArgumentParser(
description='Generate interactive CI dashboard for SmartCane'
)
parser.add_argument(
'estate',
help='Estate name (e.g., esa, aura, simba)'
)
parser.add_argument(
'--current-week',
type=int,
default=None,
help='Current week number (default: current ISO week)'
)
parser.add_argument(
'--previous-week',
type=int,
default=None,
help='Previous week number (default: current_week - 1)'
)
parser.add_argument(
'--data-dir',
default='laravel_app/storage/app',
help='Base data directory containing weekly mosaic'
)
parser.add_argument(
'--output-dir',
default='output',
help='Output directory for HTML dashboard'
)
args = parser.parse_args()
# Determine current week if not specified
if args.current_week is None:
args.current_week = datetime.now().isocalendar()[1]
if args.previous_week is None:
args.previous_week = args.current_week - 1
# Build full data path
data_dir = Path(args.data_dir) / args.estate
output_dir = Path(args.output_dir) / args.estate
try:
# Generate dashboard
dashboard = InteractiveCIDashboard(
estate_name=args.estate,
data_dir=str(data_dir),
output_dir=str(output_dir)
)
output_file = dashboard.generate_dashboard(
current_week=args.current_week,
previous_week=args.previous_week
)
if output_file:
print(f"\nTo view the dashboard, open:")
print(f" file:///{output_file}")
sys.exit(0)
else:
sys.exit(1)
except Exception as e:
print(f"\n✗ Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()