-
-
Notifications
You must be signed in to change notification settings - Fork 452
Add support for visualizing NASA fire datasets #1290
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This pull request adds support for visualizing NASA fire datasets from the Fire Event Data Suite (FEDs) algorithm via the OpenVEDA OGC API Features endpoint. The implementation addresses issue #1206 by providing a new fire module with functions to search and retrieve fire perimeter data, along with Map methods to visualize this data.
Changes:
- Added new
leafmap/fire.pymodule with functions to query fire data from OpenVEDA API - Added three new methods to Map classes (
add_fire_data,add_fire_perimeters,add_fire_timeseries) for visualizing fire data - Added documentation and example notebook demonstrating the new functionality
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
leafmap/fire.py |
New module providing functions to search and retrieve NASA fire data from OpenVEDA API |
leafmap/leafmap.py |
Added three methods to visualize fire data on ipyleaflet-based maps |
leafmap/foliumap.py |
Added three methods to visualize fire data on folium-based maps |
leafmap/__init__.py |
Exported fire module functions and constants at package level |
mkdocs.yml |
Added fire module documentation and example notebook to navigation |
docs/fire.md |
Created documentation page for fire module |
docs/notebooks/114_nasa_fire.ipynb |
Added comprehensive example notebook demonstrating fire data visualization |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def add_fire_perimeters( | ||
| self, | ||
| bbox: Optional[List[float]] = None, | ||
| place: Optional[str] = None, | ||
| collection: str = "snapshot_perimeter_nrt", | ||
| datetime: Optional[str] = None, | ||
| farea_min: Optional[float] = None, | ||
| color_column: Optional[str] = "farea", | ||
| cmap: str = "YlOrRd", | ||
| layer_name: str = "Fire Perimeters", | ||
| style: Optional[Dict] = None, | ||
| info_mode: str = "on_hover", | ||
| zoom_to_layer: bool = True, | ||
| add_legend: bool = True, | ||
| legend_title: str = "Fire Area (km²)", | ||
| **kwargs, | ||
| ) -> None: | ||
| """Add fire perimeters with color scaling based on fire attributes. | ||
|
|
||
| Args: | ||
| bbox: Bounding box [west, south, east, north] in EPSG:4326. | ||
| place: Place name to geocode (e.g., "California"). | ||
| collection: Fire collection ID. Defaults to "snapshot_perimeter_nrt". | ||
| datetime: ISO 8601 date/time or interval. | ||
| farea_min: Minimum fire area in km² to filter results. | ||
| color_column: Column to use for color scaling. Defaults to "farea". | ||
| cmap: Colormap name for color scaling. Defaults to "YlOrRd". | ||
| layer_name: Name for the layer. | ||
| style: Base style dictionary (color will be overridden by colormap). | ||
| info_mode: Display attributes "on_hover" or "on_click". | ||
| zoom_to_layer: Whether to zoom to the layer bounds. | ||
| add_legend: Whether to add a legend. Defaults to True. | ||
| legend_title: Title for the legend. | ||
| **kwargs: Additional keyword arguments for add_gdf(). | ||
|
|
||
| Example: | ||
| >>> m = leafmap.Map() | ||
| >>> m.add_fire_perimeters( | ||
| ... place="California", | ||
| ... datetime="2024-07-01/2024-07-31", | ||
| ... color_column="farea" | ||
| ... ) | ||
| >>> m | ||
| """ | ||
| from . import fire as fire_module | ||
|
|
||
| gdf = fire_module.search_fires( | ||
| bbox=bbox, | ||
| place=place, | ||
| collection=collection, | ||
| datetime=datetime, | ||
| farea_min=farea_min, | ||
| ) | ||
|
|
||
| if gdf.empty: | ||
| print("No fire data found for the specified parameters.") | ||
| return | ||
|
|
||
| # Create color-scaled styling based on the specified column | ||
| if color_column and color_column in gdf.columns: | ||
| try: | ||
| import matplotlib.pyplot as plt | ||
| import matplotlib.colors as mcolors | ||
|
|
||
| colormap = plt.get_cmap(cmap) | ||
| values = gdf[color_column].fillna(0) | ||
| vmin, vmax = values.min(), values.max() | ||
|
|
||
| if vmax > vmin: | ||
| norm = mcolors.Normalize(vmin=vmin, vmax=vmax) | ||
|
|
||
| def style_callback(feature): | ||
| value = feature["properties"].get(color_column, 0) | ||
| if value is None: | ||
| value = 0 | ||
| rgba = colormap(norm(value)) | ||
| hex_color = mcolors.rgb2hex(rgba) | ||
| base_style = style or {} | ||
| return { | ||
| "color": hex_color, | ||
| "weight": base_style.get("weight", 2), | ||
| "fillColor": hex_color, | ||
| "fillOpacity": base_style.get("fillOpacity", 0.5), | ||
| } | ||
|
|
||
| self.add_gdf( | ||
| gdf, | ||
| layer_name=layer_name, | ||
| style_callback=style_callback, | ||
| info_mode=info_mode, | ||
| zoom_to_layer=zoom_to_layer, | ||
| **kwargs, | ||
| ) | ||
|
|
||
| if add_legend: | ||
| self.add_colormap( | ||
| cmap=cmap, | ||
| vmin=vmin, | ||
| vmax=vmax, | ||
| label=legend_title, | ||
| ) | ||
| return | ||
| except ImportError: | ||
| pass | ||
|
|
||
| # Fallback to default styling | ||
| if style is None: | ||
| style = { | ||
| "color": "#FF4500", | ||
| "weight": 2, | ||
| "fillColor": "#FF6347", | ||
| "fillOpacity": 0.5, | ||
| } | ||
|
|
||
| self.add_gdf( | ||
| gdf, | ||
| layer_name=layer_name, | ||
| style=style, | ||
| info_mode=info_mode, | ||
| zoom_to_layer=zoom_to_layer, | ||
| **kwargs, | ||
| ) |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method has nearly identical implementation to the same method in leafmap.py (lines 6970-7091). Consider extracting the common logic into a shared helper function in the fire module to reduce code duplication and improve maintainability. This would make it easier to fix bugs or add features in one place.
| response = requests.get(url, timeout=30) | ||
| response.raise_for_status() |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The API request with raise_for_status() will throw an exception if the HTTP request fails, but there's no try-except block to provide a more user-friendly error message. Consider wrapping this in a try-except block to catch HTTPError and provide a more informative error message to users about what went wrong with the API call.
| response = requests.get(url, timeout=30) | |
| response.raise_for_status() | |
| try: | |
| response = requests.get(url, timeout=30) | |
| response.raise_for_status() | |
| except requests.exceptions.HTTPError as http_err: | |
| raise RuntimeError( | |
| f"Failed to retrieve fire collections from {url}: {http_err}" | |
| ) from http_err | |
| except requests.exceptions.RequestException as req_err: | |
| raise RuntimeError( | |
| f"Error while requesting fire collections from {url}: {req_err}" | |
| ) from req_err |
| def add_fire_data( | ||
| self, | ||
| data: Optional[Union[str, "gpd.GeoDataFrame"]] = None, | ||
| bbox: Optional[List[float]] = None, | ||
| place: Optional[str] = None, | ||
| collection: str = "snapshot_perimeter_nrt", | ||
| datetime: Optional[str] = None, | ||
| farea_min: Optional[float] = None, | ||
| layer_name: str = "Fire Perimeters", | ||
| style: Optional[Dict] = None, | ||
| style_callback: Optional[Callable] = None, | ||
| info_mode: str = "on_hover", | ||
| zoom_to_layer: bool = True, | ||
| **kwargs, | ||
| ) -> None: | ||
| """Add NASA fire perimeter data to the map. | ||
|
|
||
| This method fetches and displays fire perimeter data from the NASA Fire | ||
| Event Data Suite (FEDs) via the OpenVEDA OGC API Features. | ||
|
|
||
| Args: | ||
| data: Pre-loaded fire data as a file path or GeoDataFrame. If provided, | ||
| bbox and place are ignored. | ||
| bbox: Bounding box [west, south, east, north] in EPSG:4326. | ||
| place: Place name to geocode (e.g., "California"). | ||
| collection: Fire collection ID. Options: | ||
| - "snapshot_perimeter_nrt": 20-day recent fire perimeters (default) | ||
| - "lf_perimeter_nrt": Current year large fires (>5 km²) | ||
| - "lf_perimeter_archive": 2018-2021 Western US archived fires | ||
| - "lf_fireline_nrt": Active fire lines | ||
| - "lf_newfirepix_nrt": New fire pixels | ||
| datetime: ISO 8601 date/time or interval (e.g., "2024-07-01/2024-07-31"). | ||
| farea_min: Minimum fire area in km² to filter results. | ||
| layer_name: Name for the layer. Defaults to "Fire Perimeters". | ||
| style: Style dictionary for the fire perimeters. Defaults to orange/red. | ||
| style_callback: Styling function called for each feature. | ||
| info_mode: Display attributes "on_hover" or "on_click". | ||
| zoom_to_layer: Whether to zoom to the layer bounds. | ||
| **kwargs: Additional keyword arguments for add_gdf(). | ||
|
|
||
| Example: | ||
| >>> m = leafmap.Map() | ||
| >>> m.add_fire_data(place="California", datetime="2024-07-01/2024-07-31") | ||
| >>> m | ||
| """ | ||
| from . import fire as fire_module | ||
|
|
||
| if data is not None: | ||
| # Use provided data | ||
| if isinstance(data, str): | ||
| import geopandas as gpd | ||
|
|
||
| gdf = gpd.read_file(data) | ||
| else: | ||
| gdf = data | ||
| elif bbox is not None or place is not None: | ||
| # Fetch fire data | ||
| gdf = fire_module.search_fires( | ||
| bbox=bbox, | ||
| place=place, | ||
| collection=collection, | ||
| datetime=datetime, | ||
| farea_min=farea_min, | ||
| ) | ||
| else: | ||
| raise ValueError("Either data, bbox, or place must be provided") | ||
|
|
||
| if gdf.empty: | ||
| print("No fire data found for the specified parameters.") | ||
| return | ||
|
|
||
| # Default fire styling | ||
| if style is None: | ||
| style = { | ||
| "color": "#FF4500", | ||
| "weight": 2, | ||
| "fillColor": "#FF6347", | ||
| "fillOpacity": 0.5, | ||
| } | ||
|
|
||
| self.add_gdf( | ||
| gdf, | ||
| layer_name=layer_name, | ||
| style=style, | ||
| style_callback=style_callback, | ||
| info_mode=info_mode, | ||
| zoom_to_layer=zoom_to_layer, | ||
| **kwargs, | ||
| ) |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method has nearly identical implementation to the same method in leafmap.py (lines 6874-6968). The only difference is that foliumap.py doesn't include the hover_style parameter. Consider extracting the common logic into a shared helper function in the fire module to reduce code duplication and improve maintainability. This would make it easier to fix bugs or add features in one place.
| def fire_gdf_from_place( | ||
| place: str, | ||
| collection: str = "snapshot_perimeter_nrt", | ||
| datetime: Optional[str] = None, | ||
| farea_min: Optional[float] = None, | ||
| farea_max: Optional[float] = None, | ||
| duration_min: Optional[float] = None, | ||
| meanfrp_min: Optional[float] = None, | ||
| limit: int = 1000, | ||
| buffer_dist: Optional[float] = None, | ||
| ) -> "gpd.GeoDataFrame": | ||
| """Get fire perimeter data for a place by name. | ||
|
|
||
| Uses OpenStreetMap Nominatim for geocoding the place name. | ||
|
|
||
| Args: | ||
| place: Place name to geocode (e.g., "California", "Los Angeles County"). | ||
| collection: Fire collection ID. Defaults to "snapshot_perimeter_nrt". | ||
| datetime: ISO 8601 date/time or interval (e.g., "2024-07-01/2024-07-31"). | ||
| farea_min: Minimum fire area in km². | ||
| farea_max: Maximum fire area in km². | ||
| duration_min: Minimum fire duration in days. | ||
| meanfrp_min: Minimum mean Fire Radiative Power. | ||
| limit: Maximum number of features to return. | ||
| buffer_dist: Distance to buffer around the place geometry, in meters. | ||
|
|
||
| Returns: | ||
| A GeoDataFrame containing fire perimeter features. | ||
|
|
||
| Raises: | ||
| ImportError: If required packages are not installed. | ||
| ValueError: If place cannot be geocoded. | ||
|
|
||
| Example: | ||
| >>> gdf = fire_gdf_from_place("California", datetime="2024-07-01/2024-07-31") | ||
| >>> print(f"Found {len(gdf)} fires") | ||
| """ | ||
| try: | ||
| import geopandas as gpd | ||
| from shapely.geometry import box | ||
| except ImportError: | ||
| raise ImportError( | ||
| "geopandas and shapely are required. " | ||
| "Install with: pip install geopandas shapely" | ||
| ) | ||
|
|
||
| try: | ||
| import osmnx as ox | ||
| except ImportError: | ||
| raise ImportError( | ||
| "osmnx is required for geocoding. " "Install with: pip install osmnx" | ||
| ) | ||
|
|
||
| # Geocode the place name | ||
| place_gdf = ox.geocode_to_gdf(place) | ||
| if place_gdf.empty: | ||
| raise ValueError(f"Could not geocode place: {place}") | ||
|
|
||
| # Apply buffer if specified | ||
| if buffer_dist is not None and buffer_dist > 0: | ||
| # Buffer in meters requires projecting to a metric CRS | ||
| place_gdf_proj = place_gdf.to_crs(epsg=3857) | ||
| place_gdf_proj["geometry"] = place_gdf_proj.buffer(buffer_dist) | ||
| place_gdf = place_gdf_proj.to_crs(epsg=4326) | ||
|
|
||
| # Get bounding box from place geometry | ||
| bounds = place_gdf.total_bounds # [minx, miny, maxx, maxy] | ||
| bbox = [bounds[0], bounds[1], bounds[2], bounds[3]] | ||
|
|
||
| # Fetch fire data for the bounding box | ||
| gdf = fire_gdf_from_bbox( | ||
| bbox=bbox, | ||
| collection=collection, | ||
| datetime=datetime, | ||
| farea_min=farea_min, | ||
| farea_max=farea_max, | ||
| duration_min=duration_min, | ||
| meanfrp_min=meanfrp_min, | ||
| limit=limit, | ||
| ) | ||
|
|
||
| return gdf |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The fire_gdf_from_place function doesn't include a duration_max parameter, but fire_gdf_from_bbox does support it. This creates an API inconsistency where users calling fire_gdf_from_place cannot filter by maximum duration, while users calling fire_gdf_from_bbox can. Consider adding duration_max as a parameter to maintain consistency across the API.
| mask = [True] * len(gdf) | ||
|
|
||
| if farea_min is not None and "farea" in gdf.columns: | ||
| mask = mask & (gdf["farea"] >= farea_min) | ||
| if farea_max is not None and "farea" in gdf.columns: | ||
| mask = mask & (gdf["farea"] <= farea_max) | ||
| if duration_min is not None and "duration" in gdf.columns: | ||
| mask = mask & (gdf["duration"] >= duration_min) | ||
| if duration_max is not None and "duration" in gdf.columns: | ||
| mask = mask & (gdf["duration"] <= duration_max) | ||
| if meanfrp_min is not None and "meanfrp" in gdf.columns: | ||
| mask = mask & (gdf["meanfrp"] >= meanfrp_min) | ||
| if fire_id is not None and "fireid" in gdf.columns: | ||
| # Support both string and numeric fire IDs | ||
| mask = mask & (gdf["fireid"].astype(str) == str(fire_id)) |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The mask variable is initialized as a list of booleans, but then bitwise operations (&) are applied with pandas Series. This will fail at runtime because you cannot use bitwise AND (&) between a Python list and a pandas Series. The mask should be initialized as a pandas Series of True values or each condition should use logical AND to build up the mask correctly.
| A GeoDataFrame containing fire perimeter features. | ||
|
|
||
| Raises: | ||
| ImportError: If geopandas is not installed. | ||
| ValueError: If no features are found. |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docstring mentions "Raises: ValueError: If no features are found" but the code actually returns an empty GeoDataFrame instead of raising a ValueError when no features are found. The docstring should be updated to accurately reflect the actual behavior, which is to return an empty GeoDataFrame.
| A GeoDataFrame containing fire perimeter features. | |
| Raises: | |
| ImportError: If geopandas is not installed. | |
| ValueError: If no features are found. | |
| A GeoDataFrame containing fire perimeter features. If no features | |
| match the query, an empty GeoDataFrame is returned. | |
| Raises: | |
| ImportError: If geopandas is not installed. |
| response = requests.get(url, params=params, timeout=60) | ||
| response.raise_for_status() |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The API request with raise_for_status() will throw an exception if the HTTP request fails, but there's no try-except block to provide a more user-friendly error message. Consider wrapping this in a try-except block to catch HTTPError and provide a more informative error message to users about what went wrong with the API call (e.g., network issues, invalid collection ID, API temporarily unavailable).
| response = requests.get(url, params=params, timeout=60) | |
| response.raise_for_status() | |
| try: | |
| response = requests.get(url, params=params, timeout=60) | |
| response.raise_for_status() | |
| except requests.exceptions.HTTPError as http_err: | |
| raise RuntimeError( | |
| f"OpenVEDA API request failed with status {response.status_code} " | |
| f"for collection '{resolved_collection}': {http_err}" | |
| ) from http_err | |
| except requests.exceptions.RequestException as req_err: | |
| raise RuntimeError( | |
| f"Error connecting to OpenVEDA API for collection '{resolved_collection}': {req_err}" | |
| ) from req_err |
| label=legend_title, | ||
| ) | ||
| return | ||
| except ImportError: |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
'except' clause does nothing but pass and there is no explanatory comment.
| except ImportError: | |
| except ImportError: | |
| # Matplotlib is an optional dependency; if it's not available, | |
| # silently fall back to the default styling defined below. |
| label=legend_title, | ||
| ) | ||
| return | ||
| except ImportError: |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
'except' clause does nothing but pass and there is no explanatory comment.
| except ImportError: | |
| except ImportError: | |
| # matplotlib is an optional dependency; if it's not available, | |
| # skip color-scaled styling and fall back to the default styling below. |
|
🚀 Deployed on https://6978574fc005c6585c96006f--opengeos.netlify.app |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Fix #1206