diff --git a/README.md b/README.md index 7150fdc..ffbadca 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ claude mcp add mcgibs-local -- uv run --directory /path/to/mcgibs mcgibs | `get_imagery_composite` | Overlay up to 5 layers into a single composite image | | `explain_layer_colormap` | Natural-language explanation of what colors represent | | `get_legend` | Pre-rendered legend graphic for a layer | +| `query_point` | Get the exact data value at a coordinate by reverse-mapping the pixel color through the layer's colormap | +| `get_time_series` | Fetch imagery across multiple dates for temporal analysis (up to 12 frames) | | `resolve_place` | Geocode a place name to coordinates and bounding box | | `build_tile_url` | Construct a direct WMTS tile URL for embedding | @@ -78,6 +80,8 @@ claude mcp add mcgibs-local -- uv run --directory /path/to/mcgibs mcgibs | `layer_deep_dive` | `layer_id`, `location`, `date` | Full scientific analysis of a single layer | | `multi_layer_story` | `topic`, `location`, `date` | Data journalism — composite layers to tell a story | | `polar_watch` | `pole`, `date`, `compare_date` | Arctic/Antarctic ice and snow monitoring | +| `quantitative_snapshot` | `layer_id`, `locations`, `date` | Query exact data values at specific coordinates | +| `seasonal_timelapse` | `layer_id`, `location`, `start_date`, `end_date` | Track visual changes across multiple dates | ## Conversational Examples @@ -230,6 +234,122 @@ The assistant searches for complementary layers, checks dates, and builds a comp > **Assistant:** The composite overlay tells the story clearly. Over the LA basin, the true color base shows a visible smoke plume extending southwest over the Pacific. The aerosol optical depth overlay confirms it — the orange-to-red values (0.5-1.0) directly over the fire zone indicate thick particulate loading, fading to yellow-green (0.15, clean air) further out over the ocean. The fire thermal anomaly layer pinpoints the active burn areas as bright red hotspots in the foothills. +--- + +### Querying exact values at a point + +*Try this with the `quantitative_snapshot` prompt: `layer_id="AIRS_L3_Surface_Air_Temperature_Daily_Day", locations="Tokyo, Sydney, Sahara Desert", date="2025-07-15"`* + +> **You:** What's the actual surface temperature in Tokyo, Sydney, and the Sahara right now? + +The assistant queries exact data values by reverse-mapping pixel colors through the layer's colormap: + +``` +→ explain_layer_colormap("AIRS_L3_Surface_Air_Temperature_Daily_Day") + Surface Air Temperature (K): + - light blue = below 200 K (-73.1 C) + - blue = ~260 K (-13.4 C) + - orange = ~290 K (17.1 C) + - magenta = above 320 K (46.9 C) + (242 color stops total, 5 key values shown) + +→ query_point("AIRS_L3_Surface_Air_Temperature_Daily_Day", "2025-07-15", + lat=35.68, lon=139.69) + {"value": 299.2, "units": "K", "converted": "26.1 C", + "color": "orange", "quality": "exact", + "description": "mild"} + +→ query_point(..., lat=-33.87, lon=151.21) + {"value": 282.8, "units": "K", "converted": "9.6 C", + "color": "blue", "quality": "exact", + "description": "near freezing"} + +→ query_point(..., lat=25.0, lon=25.0) + {"value": 304.8, "units": "K", "converted": "31.6 C", + "color": "red", "quality": "exact", + "description": "hot"} +``` + +> **Assistant:** Here are the AIRS-measured surface air temperatures for July 15, 2025: +> +> | Location | Temperature | Condition | +> |----------|------------|-----------| +> | Tokyo | 26.1°C (299.2 K) | Mild summer day | +> | Sydney | 9.6°C (282.8 K) | Cool — it's winter in the Southern Hemisphere | +> | Sahara | 31.6°C (304.8 K) | Hot, typical for desert in July | +> +> These come from the Atmospheric Infrared Sounder (AIRS) on NASA's Aqua satellite. The resolution is about 0.5°C — each of the 242 colormap entries covers a narrow value range, so the reverse-mapped pixel color gives an exact match to the original data. + +--- + +### Watching a season unfold + +*Try this with the `seasonal_timelapse` prompt: `layer_id="MODIS_Terra_NDVI_8Day", location="Great Plains, USA", start_date="2025-03-01", end_date="2025-09-01"`* + +> **You:** Show me how vegetation changes across the US Great Plains from winter to summer. + +The assistant fetches a sequence of images at regular intervals: + +``` +→ search_gibs_layers("NDVI") + MODIS_Terra_NDVI_8Day + MODIS/Terra Vegetation Index (NDVI), 8-Day — from 2000-02-18 (8-Day) + [has colormap] + +→ explain_layer_colormap("MODIS_Terra_NDVI_8Day") + Vegetation Index (NDVI): + - tan/brown = ~0.1 (bare soil, dormant vegetation) + - yellow-green = ~0.3 (sparse vegetation) + - green = ~0.6 (moderate vegetation) + - dark green = ~0.9 (dense, healthy vegetation) + +→ get_time_series("MODIS_Terra_NDVI_8Day", + place="Great Plains USA", + start_date="2025-03-01", + end_date="2025-09-01", + steps=6) + [6 images: Mar 1, Apr 13, May 25, Jul 6, Aug 17, Sep 1] +``` + +> **Assistant:** The six frames tell a clear story of the growing season: +> +> 1. **March** — The Plains are mostly tan/brown (NDVI ~0.1), winter dormancy. Only southern Texas shows hints of green. +> 2. **April** — A flush of yellow-green creeps north from Oklahoma as winter wheat greens up. +> 3. **May** — The green wave accelerates — Kansas and Nebraska shift to solid green (NDVI ~0.5) as corn and soybean planting peaks. +> 4. **July** — Peak vegetation. The entire region is dark green (NDVI 0.7-0.9). Crop canopy is fully closed. +> 5. **August** — Still green but beginning to yellow at the margins. Early harvest in southern counties. +> 6. **September** — Harvest underway. The palette shifts back toward yellow-green and tan as crops are cut. + +--- + +### Polar ice in proper projection + +*Try this with the `polar_watch` prompt: `pole="arctic", date="2025-09-01"`* + +> **You:** Show me the current state of Arctic sea ice — and use the right map projection. + +The `polar_watch` prompt guides the assistant to use polar stereographic projection: + +``` +→ get_imagery("AMSRU2_Sea_Ice_Concentration_12km", "2025-09-01", + bbox=[-180, 60, 180, 90], + projection="3413") + [image: Arctic sea ice in polar stereographic — circular view centered on the pole] + +→ explain_layer_colormap("AMSRU2_Sea_Ice_Concentration_12km") + Sea Ice Concentration (%): + - dark blue = ~15% (scattered floes) + - cyan = ~40% + - light grey = ~70% + - white = ~100% (solid pack ice) + +→ build_tile_url("AMSRU2_Sea_Ice_Concentration_12km", "2025-09-01", + zoom=2, row=0, col=0, projection="3413") + https://gibs.earthdata.nasa.gov/wmts/epsg3413/best/AMSRU2_Sea_Ice_Concentration_12km/... +``` + +> **Assistant:** With polar stereographic projection (EPSG:3413), the Arctic is shown as a circular view centered on the North Pole — no more equirectangular distortion. The September minimum is clearly visible: solid pack ice (white, 100%) is confined to a shrinking core, surrounded by a wide margin of partial ice (blues and cyans, 15-40%). The embeddable WMTS tile URL is ready for dashboards or reports. + ## Projections | EPSG | Description | Use case | diff --git a/pyproject.toml b/pyproject.toml index 6759208..de788fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcgibs" -version = "2026.02.18.4" +version = "2026.02.19" description = "FastMCP server for NASA Global Imagery Browse Services (GIBS)" readme = "README.md" requires-python = ">=3.12" diff --git a/src/mcgibs/__init__.py b/src/mcgibs/__init__.py index 5fee8af..afb43d6 100644 --- a/src/mcgibs/__init__.py +++ b/src/mcgibs/__init__.py @@ -1 +1 @@ -__version__ = "2026.02.18.4" +__version__ = "2026.02.19" diff --git a/src/mcgibs/client.py b/src/mcgibs/client.py index a91d3ff..20c3af5 100644 --- a/src/mcgibs/client.py +++ b/src/mcgibs/client.py @@ -12,7 +12,7 @@ import httpx from PIL import Image from mcgibs.capabilities import parse_capabilities -from mcgibs.colormaps import explain_colormap, parse_colormap +from mcgibs.colormaps import explain_colormap, parse_colormap, reverse_lookup from mcgibs.constants import ( COLORMAP_BASE, DEFAULT_EPSG, @@ -296,6 +296,7 @@ class GIBSClient: width: int = 1024, height: int = 1024, image_format: str = "image/jpeg", + *, epsg: str = DEFAULT_EPSG, ) -> bytes: """Fetch a multi-layer WMS composite image.""" @@ -318,6 +319,7 @@ class GIBSClient: width: int = 1024, height: int = 512, image_format: str = "image/jpeg", + epsg: str = DEFAULT_EPSG, ) -> bytes: """Fetch two images and compose a side-by-side comparison.""" img_before = await self.get_wms_image( @@ -327,6 +329,7 @@ class GIBSClient: width, height, image_format, + epsg, ) img_after = await self.get_wms_image( layer_id, @@ -335,6 +338,7 @@ class GIBSClient: width, height, image_format, + epsg, ) # M1: convert to RGB to avoid mode mismatch with RGBA PNGs @@ -351,6 +355,66 @@ class GIBSClient: composite.save(buf, format="JPEG", quality=90) return buf.getvalue() + # --- Point query (RGB reverse-mapping) --- + + async def query_point( + self, + layer_id: str, + date: str, + lat: float, + lon: float, + epsg: str = DEFAULT_EPSG, + ) -> dict: + """Query the data value at a geographic point via RGB reverse-mapping. + + Fetches a 1x1 pixel PNG at the given coordinate and maps the pixel + color back through the layer's colormap to recover the data value. + """ + # Small bbox centered on the point (0.5 degree padding, 3x3 pixels) + bbox = BBox(west=lon - 0.5, south=lat - 0.5, east=lon + 0.5, north=lat + 0.5) + img_bytes = await self.get_wms_image( + layer_id, date, bbox, 3, 3, "image/png", epsg + ) + + img = Image.open(BytesIO(img_bytes)).convert("RGBA") + r, g, b, a = img.getpixel((1, 1)) + + if a == 0: + return {"nodata": True, "label": "No Data (transparent pixel)"} + + colormap_set = await self.fetch_colormap(layer_id) + if colormap_set is None: + return { + "color_rgb": [r, g, b], + "note": "No colormap available — cannot reverse-map to data value", + } + + result = reverse_lookup(colormap_set, (r, g, b)) + result["lat"] = lat + result["lon"] = lon + return result + + # --- Time series --- + + async def get_time_series_images( + self, + layer_id: str, + bbox: BBox, + dates: list[str], + width: int = 512, + height: int = 512, + image_format: str = "image/jpeg", + epsg: str = DEFAULT_EPSG, + ) -> list[tuple[str, bytes]]: + """Fetch imagery for multiple dates, returning (date, image_bytes) pairs.""" + results = [] + for date in dates: + img = await self.get_wms_image( + layer_id, date, bbox, width, height, image_format, epsg + ) + results.append((date, img)) + return results + # --- DescribeDomains (date ranges) --- async def describe_domains( diff --git a/src/mcgibs/colormaps.py b/src/mcgibs/colormaps.py index 48ec349..8985281 100644 --- a/src/mcgibs/colormaps.py +++ b/src/mcgibs/colormaps.py @@ -484,3 +484,69 @@ def explain_colormap(colormap_set: ColorMapSet) -> str: lines.append(f"Transparent / no-data: {', '.join(nodata_entries)}") return "\n".join(lines) + + +def reverse_lookup( + colormap_set: ColorMapSet, rgb: tuple[int, int, int] +) -> dict: + """Map an RGB pixel value back to its data value using the colormap. + + Finds the colormap entry with the closest RGB match and returns + the associated data value, units, and qualitative description. + """ + data_map = colormap_set.data_map + if data_map is None: + return {"error": "No colormap data available"} + + # Check for no-data / transparent match first + for entry in data_map.entries: + if (entry.nodata or entry.transparent) and entry.rgb == rgb: + return {"nodata": True, "label": entry.label or "No Data"} + + # Find closest data entry by RGB distance + best_dist = float("inf") + best_entry = None + for entry in data_map.entries: + if entry.transparent or entry.nodata: + continue + dr = entry.rgb[0] - rgb[0] + dg = entry.rgb[1] - rgb[1] + db = entry.rgb[2] - rgb[2] + dist = dr * dr + dg * dg + db * db + if dist < best_dist: + best_dist = dist + best_entry = entry + + if best_entry is None: + return {"error": "No matching colormap entry"} + + units = data_map.units or "" + color_name = _describe_rgb(best_entry.rgb) + result: dict = { + "color": color_name, + "quality": "exact" if best_dist == 0 else "approximate", + } + + if best_entry.label: + result["label"] = best_entry.label + return result + + low, high = _parse_interval_value(best_entry.value or "") + if low is not None and high is not None: + mid = (low + high) / 2 + result["value"] = round(mid, 2) + if units: + result["units"] = units + converter_info = _UNIT_CONVERTERS.get(units.lower().strip()) + if converter_info: + converter, target_unit = converter_info + result["converted"] = f"{converter(mid):.1f} {target_unit}" + is_temp = units.lower().strip() in ("k", "kelvin") + if is_temp: + result["description"] = _describe_temperature_feel(_kelvin_to_celsius(mid)) + elif low is not None: + result["value"] = round(low, 2) + if units: + result["units"] = units + + return result diff --git a/src/mcgibs/server.py b/src/mcgibs/server.py index 79313dc..2b58d95 100644 --- a/src/mcgibs/server.py +++ b/src/mcgibs/server.py @@ -12,6 +12,7 @@ import asyncio import atexit import json import logging +from datetime import date as Date import httpx from fastmcp import FastMCP @@ -159,7 +160,7 @@ async def search_gibs_layers( description="Get detailed metadata for a specific GIBS layer including " "dates, resolution, formats, projections, and colormap availability." ) -async def get_layer_info(layer_id: str) -> str: +async def get_layer_info(layer_id: str, ctx: Context) -> str: """Fetch full metadata for a layer. Args: @@ -170,7 +171,7 @@ async def get_layer_info(layer_id: str) -> str: if layer is None: return f"Layer '{layer_id}' not found. Use search_gibs_layers to find valid identifiers." - # Enrich with layer-metadata JSON + await ctx.report_progress(1, 2, "Fetching layer metadata...") await client.fetch_layer_metadata(layer_id) info = { @@ -212,6 +213,7 @@ async def get_layer_info(layer_id: str) -> str: "north": layer.bbox.north, } + await ctx.report_progress(2, 2, "Complete") return json.dumps(info, indent=2) @@ -240,7 +242,7 @@ async def list_measurements() -> str: @mcp.tool(description="Check available date ranges for a GIBS layer via WMTS DescribeDomains.") -async def check_layer_dates(layer_id: str) -> str: +async def check_layer_dates(layer_id: str, ctx: Context) -> str: """Query what dates are available for a specific layer. Args: @@ -267,6 +269,7 @@ async def check_layer_dates(layer_id: str) -> str: lines.append(" No time dimension (static layer)") # Try DescribeDomains for more precise info + await ctx.report_progress(1, 2, "Querying live date range...") try: domains = await client.describe_domains(layer_id) if "time_domain" in domains: @@ -274,6 +277,7 @@ async def check_layer_dates(layer_id: str) -> str: except (httpx.HTTPError, RuntimeError) as exc: log.debug("DescribeDomains failed for %s: %s", layer_id, exc) + await ctx.report_progress(2, 2, "Complete") return "\n".join(lines) @@ -300,6 +304,29 @@ async def _resolve_bbox( raise ValueError("Provide either 'bbox' [west, south, east, north] or 'place' name.") +def _pick_projection(bbox: BBox, projection: str) -> str: + """Resolve projection — auto-detect polar if set to 'auto'.""" + if projection != "auto": + return projection + if bbox.south >= 55: + return "3413" + if bbox.north <= -55: + return "3031" + return "4326" + + +def _generate_dates(start: str, end: str, steps: int) -> list[str]: + """Generate evenly-spaced dates between start and end (inclusive).""" + d0 = Date.fromisoformat(start) + d1 = Date.fromisoformat(end) + if steps <= 1: + return [start] + if steps == 2: + return [start, end] + delta = (d1 - d0) / (steps - 1) + return [(d0 + delta * i).isoformat() for i in range(steps)] + + @mcp.tool( description="Fetch satellite imagery for a specific layer, date, and region. " "Provide either a place name or bbox coordinates. Returns the image." @@ -313,6 +340,7 @@ async def get_imagery( width: int = 1024, height: int = 1024, format: str = "jpeg", + projection: str = "auto", ): """Fetch GIBS imagery via WMS. @@ -324,6 +352,7 @@ async def get_imagery( width: Image width in pixels. height: Image height in pixels. format: Image format — "jpeg" or "png". + projection: EPSG code ("4326", "3857", "3413", "3031") or "auto" to detect from bbox. """ client = _get_client() @@ -337,6 +366,8 @@ async def get_imagery( except Exception as exc: return str(exc) + epsg = _pick_projection(resolved_bbox, projection) + await ctx.report_progress(2, 4, "Fetching imagery from GIBS...") image_bytes = await client.get_wms_image( layer_id, @@ -345,6 +376,7 @@ async def get_imagery( width, height, f"image/{format}", + epsg, ) description = ( @@ -370,6 +402,7 @@ async def compare_dates( ctx: Context, bbox: list[float] | None = None, place: str | None = None, + projection: str = "auto", ): """Side-by-side comparison of two dates. @@ -379,6 +412,7 @@ async def compare_dates( date_after: Later date (YYYY-MM-DD). bbox: Bounding box as [west, south, east, north]. place: Place name to geocode. + projection: EPSG code or "auto" to detect from bbox. """ client = _get_client() @@ -392,6 +426,8 @@ async def compare_dates( except Exception as exc: return str(exc) + epsg = _pick_projection(resolved_bbox, projection) + await ctx.report_progress(2, 5, f"Fetching imagery for {date_before}...") await ctx.report_progress(3, 5, f"Fetching imagery for {date_after}...") composite_bytes = await client.compare_dates( @@ -399,6 +435,8 @@ async def compare_dates( date_before, date_after, resolved_bbox, + image_format="image/jpeg", + epsg=epsg, ) description = ( @@ -423,6 +461,7 @@ async def get_imagery_composite( place: str | None = None, width: int = 1024, height: int = 1024, + projection: str = "auto", ): """Multi-layer composite image. @@ -433,6 +472,7 @@ async def get_imagery_composite( place: Place name to geocode. width: Image width in pixels. height: Image height in pixels. + projection: EPSG code or "auto" to detect from bbox. """ client = _get_client() @@ -445,6 +485,8 @@ async def get_imagery_composite( except Exception as exc: return str(exc) + epsg = _pick_projection(resolved_bbox, projection) + layer_names = ", ".join(layer_ids) await ctx.report_progress(2, 3, f"Fetching {len(layer_ids)}-layer composite...") image_bytes = await client.get_wms_composite( @@ -453,6 +495,7 @@ async def get_imagery_composite( resolved_bbox, width, height, + epsg=epsg, ) description = ( @@ -472,19 +515,23 @@ async def get_imagery_composite( description="Explain what the colors in a GIBS layer mean. " "Returns a natural-language description mapping colors to scientific values and units." ) -async def explain_layer_colormap(layer_id: str) -> str: +async def explain_layer_colormap(layer_id: str, ctx: Context) -> str: """Get natural-language explanation of a layer's colormap. Args: layer_id: GIBS layer identifier. """ client = _get_client() - return await client.explain_layer_colormap(layer_id) + await ctx.report_progress(1, 2, "Fetching colormap...") + result = await client.explain_layer_colormap(layer_id) + await ctx.report_progress(2, 2, "Complete") + return result @mcp.tool(description="Fetch the pre-rendered legend image for a GIBS layer.") async def get_legend( layer_id: str, + ctx: Context, orientation: str = "horizontal", ): """Fetch the legend graphic for a layer. @@ -494,14 +541,113 @@ async def get_legend( orientation: "horizontal" or "vertical". """ client = _get_client() + await ctx.report_progress(1, 2, "Fetching legend...") legend_bytes = await client.get_legend_image(layer_id, orientation) if legend_bytes is None: return f"No legend available for '{layer_id}'." + await ctx.report_progress(2, 2, "Complete") return [f"Legend for {layer_id}", Image(data=legend_bytes, format="png")] +@mcp.tool( + description="Get the exact data value at a geographic coordinate. " + "Reverse-maps the pixel color through the layer's colormap to recover " + "the numeric value with units. Only works for layers with colormaps." +) +async def query_point( + layer_id: str, + date: str, + lat: float, + lon: float, + ctx: Context, +) -> str: + """Query the data value at a specific point. + + Args: + layer_id: GIBS layer identifier (must have a colormap). + date: Date in YYYY-MM-DD format. + lat: Latitude in decimal degrees. + lon: Longitude in decimal degrees. + """ + client = _get_client() + + layer = client.get_layer(layer_id) + if layer is None: + return f"Layer '{layer_id}' not found." + if not layer.has_colormap: + return f"Layer '{layer_id}' has no colormap — cannot reverse-map pixel values." + + await ctx.report_progress(1, 3, "Fetching pixel...") + result = await client.query_point(layer_id, date, lat, lon) + await ctx.report_progress(3, 3, "Complete") + + return json.dumps(result, indent=2) + + +@mcp.tool( + description="Fetch imagery for a layer across multiple dates. " + "Returns a sequence of images at regular intervals between start and end dates. " + "Useful for observing temporal progression (up to 12 frames)." +) +async def get_time_series( + layer_id: str, + start_date: str, + end_date: str, + ctx: Context, + bbox: list[float] | None = None, + place: str | None = None, + steps: int = 6, + width: int = 512, + height: int = 512, + projection: str = "auto", +): + """Fetch imagery for multiple dates in a range. + + Args: + layer_id: GIBS layer identifier. + start_date: First date (YYYY-MM-DD). + end_date: Last date (YYYY-MM-DD). + bbox: Bounding box as [west, south, east, north]. + place: Place name to geocode. + steps: Number of evenly-spaced frames (2-12). + width: Image width in pixels. + height: Image height in pixels. + projection: EPSG code or "auto" to detect from bbox. + """ + client = _get_client() + + layer = client.get_layer(layer_id) + if layer is None: + return f"Layer '{layer_id}' not found." + + steps = max(2, min(steps, 12)) + dates = _generate_dates(start_date, end_date, steps) + + await ctx.report_progress(1, steps + 2, "Resolving location...") + try: + resolved_bbox = await _resolve_bbox(client, bbox, place) + except Exception as exc: + return str(exc) + + epsg = _pick_projection(resolved_bbox, projection) + + results = [] + for i, date in enumerate(dates): + await ctx.report_progress( + i + 2, steps + 2, f"Fetching {date} ({i + 1}/{len(dates)})..." + ) + image_bytes = await client.get_wms_image( + layer_id, date, resolved_bbox, width, height, "image/jpeg", epsg + ) + results.append(f"{layer.title} — {date}") + results.append(Image(data=image_bytes, format="jpeg")) + + await ctx.report_progress(steps + 2, steps + 2, "Complete") + return results + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # TOOLS — Utility # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -511,18 +657,20 @@ async def get_legend( description="Geocode a place name to geographic coordinates and bounding box. " "Uses OpenStreetMap Nominatim." ) -async def resolve_place(place: str) -> str: +async def resolve_place(place: str, ctx: Context) -> str: """Resolve a place name to coordinates and bounding box. Args: place: Place name (e.g. "Tokyo", "California", "Great Barrier Reef"). """ client = _get_client() + await ctx.report_progress(1, 2, "Geocoding...") result = await client.resolve_place(place) if result is None: return f"Could not geocode '{place}'. Try a more specific name." + await ctx.report_progress(2, 2, "Complete") return json.dumps( { "display_name": result.display_name, @@ -944,9 +1092,11 @@ def polar_watch( if pole.lower().startswith("ant"): region = "Antarctic" bbox = "[-180, -90, 180, -60]" + projection = "3031" else: region = "Arctic" bbox = "[-180, 60, 180, 90]" + projection = "3413" lines = [ f"Monitor {region} ice and snow conditions on {date}.", @@ -961,7 +1111,9 @@ def polar_watch( f"data exists for {date} on the selected layers.", "", f"3. **Polar imagery**: Fetch imagery using the {region} " - f"bounding box {bbox}. Get true color first, then ice/snow " + f"bounding box {bbox} with projection=\"{projection}\". " + f"This polar stereographic projection shows the region properly " + f"centered on the pole. Get true color first, then ice/snow " f"concentration layers.", "", "4. **Color interpretation**: Use explain_layer_colormap on each " @@ -991,6 +1143,98 @@ def polar_watch( return "\n".join(lines) +@mcp.prompt +def quantitative_snapshot( + layer_id: str, + locations: str, + date: str, +) -> str: + """Query exact data values at specific geographic coordinates. + + Args: + layer_id: GIBS layer identifier with a colormap + (e.g. "AIRS_L3_Surface_Air_Temperature_Daily_Day"). + locations: Comma-separated place names (e.g. "Tokyo, Sydney, Sahara Desert"). + date: Date to query (YYYY-MM-DD). + """ + location_list = [loc.strip() for loc in locations.split(",")] + + lines = [ + f"Query the exact {layer_id} values at these locations on {date}: " + f"{', '.join(location_list)}.", + "", + "Follow this workflow:", + "", + f"1. **Understand the scale**: Use explain_layer_colormap on " + f'"{layer_id}" to learn the value range, units, and what ' + f"different colors represent. Fetch the legend with get_legend.", + "", + "2. **Resolve coordinates**: Use resolve_place for each location " + "to get precise lat/lon coordinates.", + "", + "3. **Query each point**: Use query_point for each location " + "with the resolved coordinates. This returns the exact data " + "value by reverse-mapping the pixel color through the colormap.", + "", + "4. **Compare and contextualize**: Present the results in a table " + "with location, value (in native and converted units if applicable), " + "and a qualitative description. Explain why the values differ — " + "latitude, altitude, season, land use, proximity to water, etc.", + "", + "5. **Verify with imagery**: Optionally fetch get_imagery for one " + "location to visually confirm the data pattern matches the " + "point query results.", + ] + return "\n".join(lines) + + +@mcp.prompt +def seasonal_timelapse( + layer_id: str, + location: str, + start_date: str, + end_date: str, +) -> str: + """Watch a location change across multiple dates. + + Args: + layer_id: GIBS layer identifier (e.g. "MODIS_Terra_NDVI_8Day"). + location: Place to observe. + start_date: Beginning of the period (YYYY-MM-DD). + end_date: End of the period (YYYY-MM-DD). + """ + lines = [ + f"Show how {layer_id} changes at {location} " + f"from {start_date} to {end_date}.", + "", + "Follow this workflow:", + "", + f"1. **Layer info**: Use get_layer_info on \"{layer_id}\" to " + f"understand what it measures, its temporal period, and resolution.", + "", + "2. **Check dates**: Use check_layer_dates to verify data " + f"covers {start_date} through {end_date}.", + "", + "3. **Color interpretation**: Use explain_layer_colormap to " + "understand the value encoding, so you can describe changes " + "in scientific terms rather than just color names.", + "", + f"4. **Time series**: Use get_time_series to fetch imagery " + f'for "{location}" from {start_date} to {end_date}. ' + f"This returns a sequence of images at regular intervals.", + "", + "5. **Narrate the progression**: For each frame, describe " + "what's visible and how it differs from the previous frame. " + "Reference colormap values to quantify the change. " + "Identify the overall trend and any notable transitions.", + "", + "6. **Bookend comparison**: Use compare_dates with " + f"{start_date} and {end_date} for a direct side-by-side " + "showing the total change across the period.", + ] + return "\n".join(lines) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # Entry point # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/tests/test_tools.py b/tests/test_tools.py index 3185b7d..247e604 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -166,6 +166,8 @@ async def test_list_tools(capabilities_xml): "get_legend", "resolve_place", "build_tile_url", + "query_point", + "get_time_series", } for name in expected: @@ -303,6 +305,8 @@ async def test_list_prompts(capabilities_xml): "layer_deep_dive", "multi_layer_story", "polar_watch", + "quantitative_snapshot", + "seasonal_timelapse", } for name in expected: @@ -358,3 +362,191 @@ async def test_colormap_resource(capabilities_xml, colormap_xml): finally: await server_module._client.close() server_module._client = None + + +# --------------------------------------------------------------------------- +# query_point tests +# --------------------------------------------------------------------------- + + +def _make_fake_png(rgb: tuple[int, int, int], size: int = 3) -> bytes: + """Create a tiny PNG with uniform color for mocking point queries.""" + buf = BytesIO() + PILImage.new("RGBA", (size, size), (*rgb, 255)).save(buf, format="PNG") + return buf.getvalue() + + +@respx.mock +async def test_query_point(capabilities_xml, colormap_xml): + """query_point reverse-maps pixel RGB through colormap to data value.""" + respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( + return_value=httpx.Response(200, text=capabilities_xml) + ) + # Return a 3x3 PNG with rgb (255,100,50) → maps to [290,300) K + respx.get(url__regex=r".*wms\.cgi.*").mock( + return_value=httpx.Response( + 200, + content=_make_fake_png((255, 100, 50)), + headers={"content-type": "image/png"}, + ) + ) + respx.get(url__regex=r".*colormaps/v1\.3/.*\.xml").mock( + return_value=httpx.Response(200, text=colormap_xml) + ) + + server_module._client = await _init_mock_client(capabilities_xml) + try: + async with Client(mcp) as client: + result = await client.call_tool( + "query_point", + { + "layer_id": "AIRS_L3_Surface_Air_Temperature_Daily_Day", + "date": "2025-06-01", + "lat": 35.67, + "lon": 139.65, + }, + ) + data = json.loads(result.content[0].text) + + assert data["lat"] == 35.67 + assert data["lon"] == 139.65 + assert data["quality"] == "exact" # exact RGB match + assert "value" in data + finally: + await server_module._client.close() + server_module._client = None + + +@respx.mock +async def test_query_point_no_colormap(capabilities_xml): + """query_point returns error for layers without colormaps.""" + respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( + return_value=httpx.Response(200, text=capabilities_xml) + ) + + server_module._client = await _init_mock_client(capabilities_xml) + try: + async with Client(mcp) as client: + result = await client.call_tool( + "query_point", + { + "layer_id": "MODIS_Terra_CorrectedReflectance_TrueColor", + "date": "2025-06-01", + "lat": 35.67, + "lon": 139.65, + }, + ) + text = result.content[0].text + assert "no colormap" in text + finally: + await server_module._client.close() + server_module._client = None + + +# --------------------------------------------------------------------------- +# get_time_series tests +# --------------------------------------------------------------------------- + + +@respx.mock +async def test_get_time_series(capabilities_xml): + """get_time_series returns multiple dated images.""" + respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( + return_value=httpx.Response(200, text=capabilities_xml) + ) + respx.get(url__regex=r".*wms\.cgi.*").mock( + return_value=httpx.Response( + 200, + content=_make_fake_jpeg(), + headers={"content-type": "image/jpeg"}, + ) + ) + + server_module._client = await _init_mock_client(capabilities_xml) + try: + async with Client(mcp) as client: + result = await client.call_tool( + "get_time_series", + { + "layer_id": "MODIS_Terra_CorrectedReflectance_TrueColor", + "start_date": "2025-01-01", + "end_date": "2025-06-01", + "bbox": [-120.0, 30.0, -110.0, 40.0], + "steps": 3, + }, + ) + # 3 steps → 3 text labels + 3 images = 6 content items + texts = [c for c in result.content if c.type == "text"] + images = [c for c in result.content if c.type == "image"] + + assert len(texts) == 3 + assert len(images) == 3 + assert "2025-01-01" in texts[0].text + assert "2025-06-01" in texts[-1].text + finally: + await server_module._client.close() + server_module._client = None + + +# --------------------------------------------------------------------------- +# New prompt tests +# --------------------------------------------------------------------------- + + +@respx.mock +async def test_quantitative_snapshot_prompt(capabilities_xml): + """quantitative_snapshot prompt includes locations and tool references.""" + respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( + return_value=httpx.Response(200, text=capabilities_xml) + ) + + server_module._client = await _init_mock_client(capabilities_xml) + try: + async with Client(mcp) as client: + result = await client.get_prompt( + "quantitative_snapshot", + { + "layer_id": "AIRS_L3_Surface_Air_Temperature_Daily_Day", + "locations": "Tokyo, Sydney", + "date": "2025-06-01", + }, + ) + text = result.messages[0].content.text + + assert "Tokyo" in text + assert "Sydney" in text + assert "query_point" in text + assert "explain_layer_colormap" in text + finally: + await server_module._client.close() + server_module._client = None + + +@respx.mock +async def test_seasonal_timelapse_prompt(capabilities_xml): + """seasonal_timelapse prompt includes dates and get_time_series reference.""" + respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( + return_value=httpx.Response(200, text=capabilities_xml) + ) + + server_module._client = await _init_mock_client(capabilities_xml) + try: + async with Client(mcp) as client: + result = await client.get_prompt( + "seasonal_timelapse", + { + "layer_id": "MODIS_Terra_CorrectedReflectance_TrueColor", + "location": "Amazon Rainforest", + "start_date": "2025-01-01", + "end_date": "2025-12-01", + }, + ) + text = result.messages[0].content.text + + assert "Amazon Rainforest" in text + assert "get_time_series" in text + assert "2025-01-01" in text + assert "2025-12-01" in text + finally: + await server_module._client.close() + server_module._client = None diff --git a/uv.lock b/uv.lock index 1eda8f3..9a48724 100644 --- a/uv.lock +++ b/uv.lock @@ -594,7 +594,7 @@ wheels = [ [[package]] name = "mcgibs" -version = "2026.2.18" +version = "2026.2.19" source = { editable = "." } dependencies = [ { name = "defusedxml" },