From a787e7d2cb0bf2f36d79a3dd337ccc93ce1c7165 Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Thu, 30 Apr 2026 11:42:05 -0500 Subject: [PATCH 01/16] add ieso agent --- akd_ext/agents/ieso_care.py | 339 ++++++++++++++++++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 akd_ext/agents/ieso_care.py diff --git a/akd_ext/agents/ieso_care.py b/akd_ext/agents/ieso_care.py new file mode 100644 index 0000000..b47971c --- /dev/null +++ b/akd_ext/agents/ieso_care.py @@ -0,0 +1,339 @@ +"""IESO CARE Agent for NASA Worldview visualization. + +This module implements the IESO CARE (Clarify, Analyze, Rank, Explain) Agent +for guided, reproducible discovery of NASA Worldview visualizations. + +Public API: + IESOAgent, IESOAgentInputSchema, IESOAgentOutputSchema, IESOAgentConfig +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import Field + +from akd._base import ( + InputSchema, + OutputSchema, + TextOutput, +) +from akd_ext.agents._base import ( + PydanticAIBaseAgent, + PydanticAIBaseAgentConfig, +) + + +# ----------------------------------------------------------------------------- +# System Prompts +# ----------------------------------------------------------------------------- + +IESO_SYSTEM_PROMPT = """ + ## **ROLE** + + You are a **NASA Worldview Scientific Data Assistant Agent**. + + You act as a **non-authoritative, transparency-first guide** that helps users: + + * Discover NASA datasets + * Understand dataset meaning, limitations, and proxies + * Configure and generate Worldview visualizations + * Perform **Worldview-native exploratory analysis only** + + You **do not interpret, conclude, recommend scientifically, or make decisions for the user**. + + ## **OBJECTIVE** + + Enable users to: + + 1. Translate their intent into scientifically relevant datasets + 2. Explore datasets via **NASA Worldview deep links** + 3. Understand dataset caveats, uncertainty, and limitations + 4. **Support visualization-driven and all the analysis workflows aligned with Worldview capabilities** + 5. Maintain **full human control over dataset selection and interpretation** + + ## **CONTEXT & INPUTS** + + ### **Available Systems & Tools** + + * **NASA Worldview (Primary Interface)** + * Generate deep links using URL parameters + * Support layers, time, comparison modes, charting + * **CMR API (Metadata Authority)** + * `search_collections`, `get_collection_metadata` + * UMM-based authoritative metadata + * **Earthdata Search Links** + * Dataset landing pages (no downloads or execution) + * **EONET** + * Event context (wildfires, storms, etc.) + * **Science Discovery Engine (Fallback)** + * Used only if dataset not found in primary sources + * **Worldview Layer Vector DB** + * Semantic mapping (non-authoritative) + * **Document Fetch Tool (ATBD/User Guide)** + * Triggered after dataset identification + + --- + + ### **User Inputs** + + * Natural language query (scientific or colloquial) + * Optional constraints: + * Time range + * Location + * Variable + * User expertise level: + * Beginner / Intermediate / Advanced (must be requested if unknown) + + ## **CONSTRAINTS & STYLE RULES** + + ### **Hard Constraints** + + * No scientific interpretation or conclusions + * No data inference or fabrication + * No predictive analysis + * No dataset ranking as final decision + * No autonomous dataset selection (user confirmation required) + * Only **pre-defined metrics and Worldview-supported analysis** + + ### **Guardrail Enforcement** + + * If violation detected → **REFUSE with explanation** + * If ambiguity → **ASK clarification** + * If partial data → **EXPLICITLY FLAG** + * Always include: + * Dataset provenance + * Uncertainty statement + * Non-authoritative disclaimer + + ### **Language Policy** + + * Avoid: + * “This shows…” + * “This means…” + * “This indicates…” + * Use: + * “This dataset represents…” + * “This visualization displays…” + * “Possible interpretation requires user judgment” + + ### **Output Style** + + Hybrid format: + + 1. **Structured schema (deterministic)** + 2. **User-adapted narrative** + 3. **Optional metadata expansion (on request)** + + ## **PROCESS** + + ### **Step 1: Intent Interpretation** + + * Extract: + * Goal + * Variables + * Constraints + * Normalize into scientific terms + * Ask clarification if ambiguity is high + + ### **Step 2: Expertise Detection** + + * Ask user to classify (Beginner / Intermediate / Advanced) + * Adapt: + * Vocabulary + * Detail level + * Explanation depth + + ### **Step 3: Feasibility Validation (HARD GATE)** + + * Check: + * Dataset availability + * Physical plausibility + * System capability + * If invalid: + * STOP + * Provide alternatives + + ### **Step 4: Dataset Retrieval** + + * Query: + * Worldview layers + * CMR metadata (Should be parallel) + * Use NASA SDE only if needed + * Do not override authoritative metadata + + ### **Step 5: Candidate Structuring** + + * Group datasets + * Explain: + * What dataset represents + * What it does NOT represent + * Proxy relationships + * Highlight a **recommended option (non-binding)** + + ### **Step 6: Mandatory User Confirmation** + + * Present options + * Ask: + * “Which dataset would you like to use?” + * DO NOT proceed without confirmation + + ### **Step 7: Visualization Construction** + + * Generate **Worldview deep link** + * Configure: + * Layers + * Time + * Viewport + * Comparison (if relevant) + * Ensure only valid parameters used + + ### **Step 8: Analysis Support (Limited)** + + * Provide: + * Time series (if supported) + * Regional statistics (if supported) + * Do NOT interpret results + + ### **Step 9: Provenance & Uncertainty** + + * Include: + * Dataset name + * Source + * Timestamp + * Resolution + * Add: + * Dataset uncertainty OR fallback statement + + ### **Step 10: Misuse Detection** + + Detect and block: + + * Causal inference + * Trend interpretation + * Invalid comparisons + * Proxy misuse + + ### **Step 11: Optional Expansion** + + Offer: + + * “Show dataset details” + * “Show metadata” + * “Open Earthdata page” + + ## **OUTPUT FORMAT** + + ### **1\. STRUCTURED RESPONSE** + + INTENT: + DATASET\_OPTIONS: + SELECTED\_DATASET: (ONLY after user confirmation) + WORLDVIEW\_URL: + + Options \[provide with more options\] + PARAMETERS\_USED: + PROVENANCE: + UNCERTAINTY: + LIMITATIONS: + MISSING\_FIELDS: + + ### **2\. USER NARRATIVE** + + * Beginner → simplified explanation + * Intermediate → moderate detail + * Advanced → technical description + + ### **3\. OPTIONAL ACTIONS** + + * View metadata + * Open dataset page + * Fetch documentation + + ### **4\. REQUIRED DISCLAIMER** + + “This information is derived from publicly available datasets and visualization tools on NASA Worldview . It is intended for exploratory and informational purposes only and does not constitute scientific analysis, interpretation, or validated conclusions.” +""" + +# ----------------------------------------------------------------------------- +# Configuration +# ----------------------------------------------------------------------------- + + +class IESOAgentConfig(PydanticAIBaseAgentConfig): + """Configuration for IESO CARE Agent.""" + + description: str = Field( + default=( + """Earth science Worldview-visualization agent. Helps users translate + Earth science queries into NASA Worldview permalinks by clarifying intent, + surfacing candidate datasets, awaiting user confirmation, and producing a + reproducible visualization URL with provenance and uncertainty annotations. + Outputs are delivered via a structured schema and interactive chat with the + user for clarification, dataset selection, approval gates, and disclaimers.""" + ) + ) + system_prompt: str = Field(default=IESO_SYSTEM_PROMPT) + model_name: str = Field(default="openai:gpt-5.2") + reasoning_effort: Literal["low", "medium", "high"] | None = Field(default="medium") + + +# ----------------------------------------------------------------------------- +# Input/Output Schemas +# ----------------------------------------------------------------------------- + + +class IESOAgentInputSchema(InputSchema): + """Input schema for the IESO Worldview-discovery agent.""" + + query: str = Field(..., description="Earth science query to interact with worldview visualization") + + +class IESOAgentOutputSchema(OutputSchema): + """Structured Worldview-discovery response. Use this on the final turn, + after the user has confirmed a dataset; populate `result` with the full + sectioned response and `url` with the Worldview permalink. + Use TextOutput for clarification questions or when no dataset has been + confirmed yet.""" + + __response_field__ = "result" + result: str = Field( + ..., + description=( + "Full sectioned response: INTENT, DATASET_OPTIONS, " + "SELECTED_DATASET, WORLDVIEW_URL, PARAMETERS_USED, PROVENANCE, " + "UNCERTAINTY, LIMITATIONS, MISSING_FIELDS, USER NARRATIVE " + "(beginner/intermediate/advanced), OPTIONAL ACTIONS, and the " + "REQUIRED DISCLAIMER. Format defined by the system prompt." + ), + ) + url: str = Field( + ..., + description="Worldview permalink that resolves the science query.", + ) + + +# ----------------------------------------------------------------------------- +# IESO Agent +# ----------------------------------------------------------------------------- + + +class IESOAgent(PydanticAIBaseAgent[IESOAgentInputSchema, IESOAgentOutputSchema]): + """Earth science Worldview-visualization agent. + + Resolves an Earth science query into a NASA Worldview permalink via a + CARE-driven (Clarify, Analyze, Rank, Explain) loop with explicit user + confirmation before dataset selection. + """ + + input_schema = IESOAgentInputSchema + output_schema = IESOAgentOutputSchema | TextOutput + config_schema = IESOAgentConfig + + def check_output(self, output) -> str | None: + if isinstance(output, IESOAgentOutputSchema): + if not output.result.strip(): + return "Result is empty. Provide the structured Worldview-discovery response." + if not output.url.strip(): + return "URL is empty. Provide a valid url" + return super().check_output(output) From 98d8cb029f59e416fb3d3d645f26e3a471d91f79 Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Thu, 30 Apr 2026 13:21:04 -0500 Subject: [PATCH 02/16] update descriptions --- akd_ext/agents/ieso_care.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/akd_ext/agents/ieso_care.py b/akd_ext/agents/ieso_care.py index b47971c..d69010a 100644 --- a/akd_ext/agents/ieso_care.py +++ b/akd_ext/agents/ieso_care.py @@ -1,7 +1,7 @@ """IESO CARE Agent for NASA Worldview visualization. -This module implements the IESO CARE (Clarify, Analyze, Rank, Explain) Agent -for guided, reproducible discovery of NASA Worldview visualizations. +This module implements the IESO Agent for guided, +reproducible discovery of NASA Worldview visualizations. Public API: IESOAgent, IESOAgentInputSchema, IESOAgentOutputSchema, IESOAgentConfig @@ -303,8 +303,8 @@ class IESOAgentOutputSchema(OutputSchema): "Full sectioned response: INTENT, DATASET_OPTIONS, " "SELECTED_DATASET, WORLDVIEW_URL, PARAMETERS_USED, PROVENANCE, " "UNCERTAINTY, LIMITATIONS, MISSING_FIELDS, USER NARRATIVE " - "(beginner/intermediate/advanced), OPTIONAL ACTIONS, and the " - "REQUIRED DISCLAIMER. Format defined by the system prompt." + "OPTIONAL ACTIONS, and the REQUIRED DISCLAIMER" + "Format is defined by the system prompt." ), ) url: str = Field( @@ -321,9 +321,7 @@ class IESOAgentOutputSchema(OutputSchema): class IESOAgent(PydanticAIBaseAgent[IESOAgentInputSchema, IESOAgentOutputSchema]): """Earth science Worldview-visualization agent. - Resolves an Earth science query into a NASA Worldview permalink via a - CARE-driven (Clarify, Analyze, Rank, Explain) loop with explicit user - confirmation before dataset selection. + Resolves an Earth science query into a NASA Worldview permalink. """ input_schema = IESOAgentInputSchema From bef76fb79882fdf7bd69bef2fd93097b16317eac Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Thu, 30 Apr 2026 15:23:31 -0500 Subject: [PATCH 03/16] rename ieso worldview agent --- .../{ieso_care.py => ieso.worldview.py} | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) rename akd_ext/agents/{ieso_care.py => ieso.worldview.py} (92%) diff --git a/akd_ext/agents/ieso_care.py b/akd_ext/agents/ieso.worldview.py similarity index 92% rename from akd_ext/agents/ieso_care.py rename to akd_ext/agents/ieso.worldview.py index d69010a..81772f4 100644 --- a/akd_ext/agents/ieso_care.py +++ b/akd_ext/agents/ieso.worldview.py @@ -4,7 +4,7 @@ reproducible discovery of NASA Worldview visualizations. Public API: - IESOAgent, IESOAgentInputSchema, IESOAgentOutputSchema, IESOAgentConfig + IESOWorldviewAgent, IESOWorldviewAgentInputSchema, IESOWorldviewAgentOutputSchema, IESOWorldviewAgentConfig """ from __future__ import annotations @@ -28,7 +28,7 @@ # System Prompts # ----------------------------------------------------------------------------- -IESO_SYSTEM_PROMPT = """ +IESO_WORLDVIEW_AGENT_SYSTEM_PROMPT = """ ## **ROLE** You are a **NASA Worldview Scientific Data Assistant Agent**. @@ -260,7 +260,7 @@ # ----------------------------------------------------------------------------- -class IESOAgentConfig(PydanticAIBaseAgentConfig): +class IESOWorldviewAgentConfig(PydanticAIBaseAgentConfig): """Configuration for IESO CARE Agent.""" description: str = Field( @@ -273,7 +273,7 @@ class IESOAgentConfig(PydanticAIBaseAgentConfig): user for clarification, dataset selection, approval gates, and disclaimers.""" ) ) - system_prompt: str = Field(default=IESO_SYSTEM_PROMPT) + system_prompt: str = Field(default=IESO_WORLDVIEW_AGENT_SYSTEM_PROMPT) model_name: str = Field(default="openai:gpt-5.2") reasoning_effort: Literal["low", "medium", "high"] | None = Field(default="medium") @@ -283,13 +283,13 @@ class IESOAgentConfig(PydanticAIBaseAgentConfig): # ----------------------------------------------------------------------------- -class IESOAgentInputSchema(InputSchema): +class IESOWorldviewAgentInputSchema(InputSchema): """Input schema for the IESO Worldview-discovery agent.""" query: str = Field(..., description="Earth science query to interact with worldview visualization") -class IESOAgentOutputSchema(OutputSchema): +class IESOWorldviewAgentOutputSchema(OutputSchema): """Structured Worldview-discovery response. Use this on the final turn, after the user has confirmed a dataset; populate `result` with the full sectioned response and `url` with the Worldview permalink. @@ -318,18 +318,18 @@ class IESOAgentOutputSchema(OutputSchema): # ----------------------------------------------------------------------------- -class IESOAgent(PydanticAIBaseAgent[IESOAgentInputSchema, IESOAgentOutputSchema]): +class IESOWorldviewAgent(PydanticAIBaseAgent[IESOWorldviewAgentInputSchema, IESOWorldviewAgentOutputSchema]): """Earth science Worldview-visualization agent. Resolves an Earth science query into a NASA Worldview permalink. """ - input_schema = IESOAgentInputSchema - output_schema = IESOAgentOutputSchema | TextOutput - config_schema = IESOAgentConfig + input_schema = IESOWorldviewAgentInputSchema + output_schema = IESOWorldviewAgentOutputSchema | TextOutput + config_schema = IESOWorldviewAgentConfig def check_output(self, output) -> str | None: - if isinstance(output, IESOAgentOutputSchema): + if isinstance(output, IESOWorldviewAgentOutputSchema): if not output.result.strip(): return "Result is empty. Provide the structured Worldview-discovery response." if not output.url.strip(): From 5163c9849f17a4042d8c15c5c0cad0133494a3e7 Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Mon, 4 May 2026 12:08:34 -0500 Subject: [PATCH 04/16] Add worldview permalink generator utility function with tests --- akd_ext/agents/__init__.py | 11 + akd_ext/tools/worldview/__init__.py | 3 + akd_ext/tools/worldview/utils.py | 315 ++++++++++++++++++++++++++++ pyproject.toml | 1 + tests/tools/worldview/__init__.py | 0 tests/tools/worldview/test_utils.py | 271 ++++++++++++++++++++++++ uv.lock | 2 + 7 files changed, 603 insertions(+) create mode 100644 akd_ext/tools/worldview/__init__.py create mode 100644 akd_ext/tools/worldview/utils.py create mode 100644 tests/tools/worldview/__init__.py create mode 100644 tests/tools/worldview/test_utils.py diff --git a/akd_ext/agents/__init__.py b/akd_ext/agents/__init__.py index fab0416..3eebe76 100644 --- a/akd_ext/agents/__init__.py +++ b/akd_ext/agents/__init__.py @@ -28,6 +28,13 @@ GapAgentOutputSchema, ) +from akd_ext.agents.ieso.worldview import ( + IESOWorldviewAgent, + IESOWorldviewAgentConfig, + IESOWorldviewAgentInputSchema, + IESOWorldviewAgentOutputSchema, +) + from akd_ext.agents.closed_loop.cm1 import ( CM1CapabilityFeasibilityMapperAgent, CM1CapabilityFeasibilityMapperConfig, @@ -85,6 +92,10 @@ "GapAgentConfig", "GapAgentInputSchema", "GapAgentOutputSchema", + "IESOWorldviewAgent", + "IESOWorldviewAgentConfig", + "IESOWorldviewAgentInputSchema", + "IESOWorldviewAgentOutputSchema", # CM1-specialized agents "CM1CapabilityFeasibilityMapperAgent", "CM1CapabilityFeasibilityMapperConfig", diff --git a/akd_ext/tools/worldview/__init__.py b/akd_ext/tools/worldview/__init__.py new file mode 100644 index 0000000..f2e1073 --- /dev/null +++ b/akd_ext/tools/worldview/__init__.py @@ -0,0 +1,3 @@ +from akd_ext.tools.worldview.utils import LayerSpec, build_worldview_permalink + +__all__ = ["LayerSpec", "build_worldview_permalink"] diff --git a/akd_ext/tools/worldview/utils.py b/akd_ext/tools/worldview/utils.py new file mode 100644 index 0000000..25c8f6b --- /dev/null +++ b/akd_ext/tools/worldview/utils.py @@ -0,0 +1,315 @@ +from datetime import date, datetime, timezone +from typing import Literal +from urllib.parse import urlencode + +from dateutil import parser as date_parser +from pydantic import BaseModel, Field + + +class LayerSpec(BaseModel): + """A single Worldview layer, optionally with rendering modifiers. + + Field defaults match Worldview's own defaults; omitted fields are not + emitted into the URL. + """ + + id: str = Field( + ..., + description=( + "GIBS layer identifier (e.g. 'MODIS_Terra_CorrectedReflectance_TrueColor', " + "'VIIRS_SNPP_AOD'). Stable strings published by NASA's GIBS service." + ), + ) + hidden: bool = Field( + default=False, + description=( + "If True, the layer is included in the layer stack but rendered invisibly. " + "Useful for pre-loading toggleable layers without rebuilding the link." + ), + ) + opacity: float | None = Field( + default=None, + description="Layer opacity, 0.0 (fully transparent) to 1.0 (fully opaque). None for full opacity.", + ) + palettes: list[str] | None = Field( + default=None, + description=( + "Custom palette IDs to apply, in order. Only meaningful for raster layers that support palette swapping." + ), + ) + style: str | None = Field( + default=None, + description="Vector style ID. Only meaningful for vector layers.", + ) + min: float | None = Field( + default=None, + description="Lower bound of the palette/data range. Set together with `max` to clamp the visible range.", + ) + max: float | None = Field( + default=None, + description="Upper bound of the palette/data range.", + ) + squash: bool = Field( + default=False, + description=( + "If True, the palette is squashed to the designated min/max values " + "rather than spanning the layer's full data range." + ), + ) + + +def _fmt_num(n: float | int) -> str: + if isinstance(n, float) and n.is_integer(): + return str(int(n)) + return str(n) + + +def _format_layer(spec: LayerSpec) -> str: + tokens: list[str] = [] + if spec.hidden: + tokens.append("hidden") + if spec.opacity is not None: + tokens.append(f"opacity={_fmt_num(spec.opacity)}") + if spec.palettes: + tokens.append(f"palettes={','.join(spec.palettes)}") + if spec.style is not None: + tokens.append(f"style={spec.style}") + if spec.min is not None: + tokens.append(f"min={_fmt_num(spec.min)}") + if spec.max is not None: + tokens.append(f"max={_fmt_num(spec.max)}") + if spec.squash: + tokens.append("squash") + if not tokens: + return spec.id + return f"{spec.id}({','.join(tokens)})" + + +def _format_time(t: str | date | datetime | None) -> str | None: + if t is None: + return None + if isinstance(t, str): + try: + t = date_parser.parse(t) + except (ValueError, OverflowError) as e: + raise ValueError(f"Could not parse time {t!r}: {e}") from e + if isinstance(t, datetime): + if t.tzinfo is not None: + t = t.astimezone(timezone.utc) + if t.time() == datetime.min.time(): + return t.date().isoformat() + return t.strftime("%Y-%m-%dT%H:%M:%SZ") + if isinstance(t, date): + return t.isoformat() + raise TypeError(f"Unsupported time type: {type(t).__name__}") + + +def build_worldview_permalink( + layers: list[LayerSpec], + projection: Literal["geographic", "arctic", "antarctic"] = "geographic", + base_url: str = "https://worldview.earthdata.nasa.gov/", + time: str | date | datetime | None = None, + bbox: tuple[float, float, float, float] | None = None, + rotation: float | None = None, + *, + compare_active: bool | None = None, + compare_layers: list[LayerSpec] | None = None, + compare_time: str | date | datetime | None = None, + compare_mode: Literal["swipe", "spy", "opacity"] = "swipe", + compare_value: int = 50, + chart_active: bool = False, + chart_layer: str | None = None, + chart_area: tuple[float, float, float, float] | None = None, + chart_time_start: str | date | datetime | None = None, + chart_time_end: str | date | datetime | None = None, + chart_autoload: bool = False, +) -> str: + """Build a NASA Worldview permalink URL. + + Generates a deep link to NASA Worldview that opens the map at a specific + layer configuration, time, viewport, and (optionally) compare or charting + state. Pure URL string assembly — no I/O. + + This function is the underlying implementation for a future + WorldviewPermalinkTool; parameter descriptions here are written as + agent-facing guidance and will map 1:1 to Pydantic Field descriptions on + the tool's input schema. + + Args: + layers: One or more LayerSpec instances, in render order (top of + stack last). Each LayerSpec carries a GIBS layer ID plus + optional per-layer modifiers (hidden, opacity, palettes, style, + min/max, squash). REQUIRED — at least one layer must be supplied. + projection: Map projection. Use 'geographic' for global Mercator (the + default), 'arctic' for north polar stereographic, or 'antarctic' + for south polar stereographic. + base_url: Worldview base URL. Override only for testing or alternate + deployments; default is the canonical production URL. + time: Map time. Accepts a date (daily resolution), a datetime + (subdaily, normalised to UTC), or a string in any reasonable + date/datetime format — ISO 8601, 'Sep 15, 2025', '2025/09/15', + TZ-aware forms, etc. (parsed via dateutil). If None, Worldview + defaults to today. Note: ambiguous slash-separated strings like + '01/02/2025' are interpreted as month/day/year by default; + pass an unambiguous form ('2025-01-02') or a date object if + the order matters. + bbox: Map viewport extent as (west, south, east, north). Degrees + for the geographic projection; projected meters for arctic/ + antarctic. If None, Worldview opens at its default global extent. + rotation: Map rotation in degrees, range -180 to 180. Honored only + by arctic/antarctic projections; ignored by geographic. + + compare_active: Activates Worldview's compare mode (side-by-side or + overlay of two layer states). Tri-state: + * None (default) — compare mode OFF; no compare params emitted + and any other compare_* args are silently ignored. + * True — compare mode ON with the A state shown as active. + * False — compare mode ON with the B state shown as active. + Maps to the URL `ca` param (None → omit; True → ca=true; False + → ca=false). + compare_layers: LayerSpec instances for the B state, same shape as + `layers`. REQUIRED when compare_active is not None; raises + ValueError if missing. + compare_time: Time for the B state, same accepted forms as `time`. + Optional even when compare is on; if omitted, the B state uses + the same time as the A state. + compare_mode: Comparison style. 'swipe' (vertical swiper between A + and B), 'spy' (lens-style hover view of B over A), or 'opacity' + (A overlaid on B with adjustable opacity). Only consulted when + compare is active. Default 'swipe' matches Worldview's default. + compare_value: Position of the swiper or value of the opacity + overlay, integer 0–100. Only consulted when compare is active. + Default 50. + + chart_active: Activates Worldview's charting mode (time-series of + regional statistics over a drawn area). False (default) → + charting OFF; no chart params emitted and any other chart_* args + are silently ignored. True → charting ON; emits cha=true. + chart_layer: GIBS layer ID to chart. Charting supports one layer + at a time. REQUIRED when chart_active=True; raises ValueError + if missing. + chart_area: Area-of-interest for the chart, as + (x1, y1, x2, y2) in the same coordinate system as `bbox`. + Statistics are computed over this region. + chart_time_start: Start of the chart's time range, same accepted + forms as `time`. Maps to the URL `cht` param. + chart_time_end: End of the chart's time range. Maps to the URL + `cht2` param. + chart_autoload: If True, the chart computes and renders the moment + the link is opened. Default False (user must click "Generate + Chart" in the Worldview UI). + + Returns: + A complete Worldview permalink URL string. + + Raises: + ValueError: If compare_active is not None but compare_layers is None. + ValueError: If chart_active is True but chart_layer is None. + + Examples: + Core layer + time + bbox:: + + url = build_worldview_permalink( + layers=[LayerSpec(id="MODIS_Terra_CorrectedReflectance_TrueColor")], + time="2025-09-15", + bbox=(-125, 32, -114, 42), + ) + + Custom opacity on one layer, compare mode A active, swipe at 60%:: + + url = build_worldview_permalink( + layers=[LayerSpec(id="MODIS_Terra_AOD", opacity=0.8)], + compare_active=True, + compare_layers=[LayerSpec(id="MODIS_Aqua_AOD")], + compare_time="2025-09-14", + compare_mode="swipe", + compare_value=60, + ) + + Charting (time series over a region):: + + url = build_worldview_permalink( + layers=[LayerSpec(id="VIIRS_SNPP_AOD")], + chart_active=True, + chart_layer="VIIRS_SNPP_AOD", + chart_area=(-125, 32, -114, 42), + chart_time_start="2025-09-01", + chart_time_end="2025-09-30", + chart_autoload=True, + ) + """ + params: dict[str, str] = {} + + params["l"] = ",".join(_format_layer(s) for s in layers) + + if compare_active is not None: + if compare_layers is None: + raise ValueError( + "compare_active is set, so compare_layers is required " + "(cannot enable compare mode without a B-state layer list)" + ) + params["l1"] = ",".join(_format_layer(s) for s in compare_layers) + + if (formatted := _format_time(time)) is not None: + params["t"] = formatted + + if compare_active is not None: + if (formatted := _format_time(compare_time)) is not None: + params["t1"] = formatted + + if bbox is not None: + params["v"] = ",".join(_fmt_num(x) for x in bbox) + + params["p"] = projection + + if rotation is not None: + params["r"] = _fmt_num(rotation) + + if compare_active is not None: + params["ca"] = "true" if compare_active else "false" + params["cm"] = compare_mode + params["cv"] = str(compare_value) + + if chart_active: + if chart_layer is None: + raise ValueError( + "chart_active is True, so chart_layer is required (cannot enable charting without a layer to chart)" + ) + params["cha"] = "true" + params["chl"] = chart_layer + if chart_area is not None: + params["chc"] = ",".join(_fmt_num(x) for x in chart_area) + if (formatted := _format_time(chart_time_start)) is not None: + params["cht"] = formatted + if (formatted := _format_time(chart_time_end)) is not None: + params["cht2"] = formatted + params["chch"] = "true" if chart_autoload else "false" + + return f"{base_url}?{urlencode(params, safe=',()=:')}" + + +if __name__ == "__main__": + core = build_worldview_permalink( + layers=[LayerSpec(id="MODIS_Terra_CorrectedReflectance_TrueColor")], + time="2025-09-15", + bbox=(-125, 32, -114, 42), + ) + print("Core:", core) + + rich = build_worldview_permalink( + layers=[LayerSpec(id="MODIS_Terra_AOD", opacity=0.8)], + time="September 15, 2025", + bbox=(-125, 32, -114, 42), + compare_active=True, + compare_layers=[LayerSpec(id="MODIS_Aqua_AOD")], + compare_time="2025-09-14", + compare_mode="swipe", + compare_value=60, + chart_active=True, + chart_layer="MODIS_Terra_AOD", + chart_area=(-125, 32, -114, 42), + chart_time_start="2025-09-01", + chart_time_end="2025-09-30", + chart_autoload=True, + ) + print("Rich:", rich) diff --git a/pyproject.toml b/pyproject.toml index 5143ede..efa2e67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "openai-agents>=0.6.7", "pydantic-ai>=1.81.0", "PyGithub>=2.1.1", + "python-dateutil>=2.8", ] [project.urls] diff --git a/tests/tools/worldview/__init__.py b/tests/tools/worldview/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tools/worldview/test_utils.py b/tests/tools/worldview/test_utils.py new file mode 100644 index 0000000..43d5672 --- /dev/null +++ b/tests/tools/worldview/test_utils.py @@ -0,0 +1,271 @@ +"""Unit tests for worldview utils module.""" + +from datetime import date, datetime, timedelta, timezone + +import pytest +from pydantic import ValidationError + +from akd_ext.tools.worldview.utils import ( + LayerSpec, + build_worldview_permalink, +) + + +def query_string(url: str) -> str: + """Return the part of the URL after `?`.""" + return url.split("?", 1)[1] + + +class TestLayerFormatting: + """LayerSpec → URL token formatting.""" + + def test_layerspec_id_is_required(self): + with pytest.raises(ValidationError): + LayerSpec(opacity=0.5) # type: ignore[call-arg] + + def test_all_defaults_renders_bare_id(self): + url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X")]) + assert "l=LAYER_X" in url + assert "(" not in query_string(url) + + def test_hidden(self): + url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", hidden=True)]) + assert "l=LAYER_X(hidden)" in url + + def test_opacity(self): + url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", opacity=0.7)]) + assert "l=LAYER_X(opacity=0.7)" in url + + def test_palettes(self): + url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", palettes=["red", "blue"])]) + assert "l=LAYER_X(palettes=red,blue)" in url + + def test_style(self): + url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", style="vector_style")]) + assert "l=LAYER_X(style=vector_style)" in url + + def test_min_max(self): + url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", min=0, max=100)]) + assert "l=LAYER_X(min=0,max=100)" in url + + def test_squash(self): + url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", squash=True)]) + assert "l=LAYER_X(squash)" in url + + def test_multiple_modifiers_use_documented_token_order(self): + # _format_layer order: hidden, opacity, palettes, style, min, max, squash + url = build_worldview_permalink( + layers=[ + LayerSpec( + id="LAYER_X", + hidden=True, + opacity=0.5, + palettes=["red"], + squash=True, + ) + ] + ) + assert "l=LAYER_X(hidden,opacity=0.5,palettes=red,squash)" in url + + def test_multiple_layers_comma_joined(self): + url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_A"), LayerSpec(id="LAYER_B", opacity=0.5)]) + assert "l=LAYER_A,LAYER_B(opacity=0.5)" in url + + +class TestTimeFormatting: + """Time conversion behaviour via the `time` param.""" + + def test_date_emits_daily_form(self): + url = build_worldview_permalink(layers=[LayerSpec(id="L")], time=date(2025, 9, 15)) + assert "t=2025-09-15" in url + assert "T" not in url.split("t=")[1].split("&")[0] + + def test_datetime_with_time_emits_subdaily(self): + url = build_worldview_permalink( + layers=[LayerSpec(id="L")], + time=datetime(2025, 9, 15, 12, 30, 45), + ) + assert "t=2025-09-15T12:30:45Z" in url + + def test_datetime_at_midnight_emits_daily(self): + url = build_worldview_permalink( + layers=[LayerSpec(id="L")], + time=datetime(2025, 9, 15, 0, 0, 0), + ) + t_segment = url.split("t=")[1].split("&")[0] + assert t_segment == "2025-09-15" + + def test_tz_aware_datetime_normalised_to_utc(self): + est = timezone(timedelta(hours=-5)) + dt = datetime(2025, 9, 15, 12, 0, 0, tzinfo=est) + url = build_worldview_permalink(layers=[LayerSpec(id="L")], time=dt) + assert "t=2025-09-15T17:00:00Z" in url + + def test_string_iso_date(self): + url = build_worldview_permalink(layers=[LayerSpec(id="L")], time="2025-09-15") + assert "t=2025-09-15" in url + + def test_string_human_readable(self): + url = build_worldview_permalink(layers=[LayerSpec(id="L")], time="September 15, 2025") + assert "t=2025-09-15" in url + + def test_string_slash_form(self): + url = build_worldview_permalink(layers=[LayerSpec(id="L")], time="2025/09/15") + assert "t=2025-09-15" in url + + def test_string_tz_aware_iso_normalises_to_utc(self): + url = build_worldview_permalink(layers=[LayerSpec(id="L")], time="2025-09-15T12:00:00-05:00") + assert "t=2025-09-15T17:00:00Z" in url + + def test_unparseable_string_raises(self): + with pytest.raises(ValueError, match="banana"): + build_worldview_permalink(layers=[LayerSpec(id="L")], time="banana") + + def test_none_omits_param(self): + url = build_worldview_permalink(layers=[LayerSpec(id="L")]) + assert "&t=" not in url and not url.endswith("?t=") + + +class TestCoreParams: + """bbox, projection, rotation.""" + + def test_bbox_round_trip(self): + url = build_worldview_permalink( + layers=[LayerSpec(id="L")], + bbox=(-125, 32, -114, 42), + ) + assert "v=-125,32,-114,42" in url + + def test_projection_default_is_geographic(self): + url = build_worldview_permalink(layers=[LayerSpec(id="L")]) + assert "p=geographic" in url + + def test_projection_arctic_with_rotation(self): + url = build_worldview_permalink( + layers=[LayerSpec(id="L")], + projection="arctic", + rotation=45, + ) + assert "p=arctic" in url + assert "r=45" in url + + def test_no_rotation_omits_param(self): + url = build_worldview_permalink(layers=[LayerSpec(id="L")]) + assert "r=" not in query_string(url) + + +class TestCompareMode: + """compare_active gate behaviour.""" + + def test_gate_on_a_side_emits_full_block(self): + url = build_worldview_permalink( + layers=[LayerSpec(id="L_A")], + time="2025-09-15", + compare_active=True, + compare_layers=[LayerSpec(id="L_B")], + compare_time="2025-09-14", + compare_mode="swipe", + compare_value=50, + ) + assert "l1=L_B" in url + assert "t1=2025-09-14" in url + assert "ca=true" in url + assert "cm=swipe" in url + assert "cv=50" in url + + def test_gate_on_b_side_emits_ca_false(self): + url = build_worldview_permalink( + layers=[LayerSpec(id="L_A")], + compare_active=False, + compare_layers=[LayerSpec(id="L_B")], + ) + assert "ca=false" in url + assert "l1=L_B" in url + + def test_gate_off_short_circuits_stray_args(self): + url = build_worldview_permalink( + layers=[LayerSpec(id="L_A")], + compare_active=None, + compare_layers=[LayerSpec(id="L_B")], + compare_time="2025-09-14", + compare_mode="opacity", + compare_value=80, + ) + qs = query_string(url) + assert "l1=" not in qs + assert "t1=" not in qs + assert "ca=" not in qs + assert "cm=" not in qs + assert "cv=" not in qs + + def test_gate_on_without_compare_layers_raises(self): + with pytest.raises(ValueError, match="compare_layers is required"): + build_worldview_permalink( + layers=[LayerSpec(id="L_A")], + compare_active=True, + compare_layers=None, + ) + + def test_compare_time_is_optional_when_gate_on(self): + url = build_worldview_permalink( + layers=[LayerSpec(id="L_A")], + compare_active=True, + compare_layers=[LayerSpec(id="L_B")], + ) + assert "ca=true" in url + assert "l1=L_B" in url + assert "t1=" not in query_string(url) + + +class TestChartingMode: + """chart_active gate behaviour.""" + + def test_gate_on_emits_full_block(self): + url = build_worldview_permalink( + layers=[LayerSpec(id="L")], + chart_active=True, + chart_layer="L_CHART", + chart_area=(-125, 32, -114, 42), + chart_time_start="2025-09-01", + chart_time_end="2025-09-30", + chart_autoload=True, + ) + assert "cha=true" in url + assert "chl=L_CHART" in url + assert "chc=-125,32,-114,42" in url + assert "cht=2025-09-01" in url + assert "cht2=2025-09-30" in url + assert "chch=true" in url + + def test_gate_off_short_circuits_stray_args(self): + url = build_worldview_permalink( + layers=[LayerSpec(id="L")], + chart_active=False, + chart_layer="L_CHART", + chart_area=(-125, 32, -114, 42), + chart_time_start="2025-09-01", + chart_autoload=True, + ) + qs = query_string(url) + assert "cha=" not in qs + assert "chl=" not in qs + assert "chc=" not in qs + assert "cht=" not in qs + assert "cht2=" not in qs + assert "chch=" not in qs + + def test_gate_on_without_chart_layer_raises(self): + with pytest.raises(ValueError, match="chart_layer is required"): + build_worldview_permalink( + layers=[LayerSpec(id="L")], + chart_active=True, + chart_layer=None, + ) + + def test_chart_autoload_default_false(self): + url = build_worldview_permalink( + layers=[LayerSpec(id="L")], + chart_active=True, + chart_layer="L_CHART", + ) + assert "chch=false" in url diff --git a/uv.lock b/uv.lock index 15e5839..75883bb 100644 --- a/uv.lock +++ b/uv.lock @@ -204,6 +204,7 @@ dependencies = [ { name = "openai-agents" }, { name = "pydantic-ai" }, { name = "pygithub" }, + { name = "python-dateutil" }, ] [package.optional-dependencies] @@ -237,6 +238,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0.0" }, + { name = "python-dateutil", specifier = ">=2.8" }, ] provides-extras = ["dev"] From ecd74c64ca12693aecda5b4615d3f0a9f25d2f54 Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Mon, 4 May 2026 14:17:25 -0500 Subject: [PATCH 05/16] add permalink generation tool --- akd_ext/tools/worldview/__init__.py | 13 +- akd_ext/tools/worldview/permalink.py | 229 ++++++++++++++++++++++++ tests/tools/worldview/test_permalink.py | 104 +++++++++++ 3 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 akd_ext/tools/worldview/permalink.py create mode 100644 tests/tools/worldview/test_permalink.py diff --git a/akd_ext/tools/worldview/__init__.py b/akd_ext/tools/worldview/__init__.py index f2e1073..2de5088 100644 --- a/akd_ext/tools/worldview/__init__.py +++ b/akd_ext/tools/worldview/__init__.py @@ -1,3 +1,14 @@ +from akd_ext.tools.worldview.permalink import ( + WorldviewPermalinkInputSchema, + WorldviewPermalinkOutputSchema, + WorldviewPermalinkTool, +) from akd_ext.tools.worldview.utils import LayerSpec, build_worldview_permalink -__all__ = ["LayerSpec", "build_worldview_permalink"] +__all__ = [ + "LayerSpec", + "WorldviewPermalinkInputSchema", + "WorldviewPermalinkOutputSchema", + "WorldviewPermalinkTool", + "build_worldview_permalink", +] diff --git a/akd_ext/tools/worldview/permalink.py b/akd_ext/tools/worldview/permalink.py new file mode 100644 index 0000000..2d6310a --- /dev/null +++ b/akd_ext/tools/worldview/permalink.py @@ -0,0 +1,229 @@ +"""AKD Tool for the NASA Worldview permalink builder. + +Wraps `build_worldview_permalink` from `utils.py` as a `BaseTool`. Field descriptions on the input +schema are written as agent-facing guidance and mirror the docstring of the +underlying function. +""" + +import os +from datetime import date, datetime +from typing import Literal, Self + +from akd._base import InputSchema, OutputSchema +from akd.tools import BaseTool, BaseToolConfig +from pydantic import Field, model_validator + +from akd_ext.mcp import mcp_tool +from akd_ext.tools.worldview.utils import LayerSpec, build_worldview_permalink + + +class WorldviewPermalinkToolConfig(BaseToolConfig): + """Configuration for the WorldviewPermalinkTool Tool.""" + + base_url: str = Field( + default=os.getenv("WORLDVIEW_BASE_URL", "https://worldview.earthdata.nasa.gov/"), + description="Base URL for the NASA WORLDVIEW", + ) + + +class WorldviewPermalinkInputSchema(InputSchema): + """Input schema for the Worldview Permalink Tool.""" + + layers: list[LayerSpec] = Field( + ..., + description=( + "One or more LayerSpec instances, in render order (top of stack last). " + "Each LayerSpec carries a GIBS layer ID plus optional per-layer modifiers " + "(hidden, opacity, palettes, style, min/max, squash). REQUIRED — at least " + "one layer must be supplied." + ), + ) + projection: Literal["geographic", "arctic", "antarctic"] = Field( + default="geographic", + description=( + "Map projection. 'geographic' for global Mercator (the default), 'arctic' " + "for north polar stereographic, or 'antarctic' for south polar stereographic." + ), + ) + time: str | date | datetime | None = Field( + default=None, + description=( + "Map time. Accepts a date (daily resolution), a datetime (subdaily, " + "normalised to UTC), or a string in any reasonable date/datetime format — " + "ISO 8601, 'Sep 15, 2025', '2025/09/15', TZ-aware forms, etc. (parsed via " + "dateutil). If None, Worldview defaults to today. Note: ambiguous " + "slash-separated strings like '01/02/2025' are interpreted as month/day/year " + "by default; pass an unambiguous form ('2025-01-02') if the order matters." + ), + ) + bbox: tuple[float, float, float, float] | None = Field( + default=None, + description=( + "Map viewport extent as (west, south, east, north). Degrees for the " + "geographic projection; projected meters for arctic/antarctic. " + "If None, Worldview opens at its default global extent." + ), + ) + rotation: float | None = Field( + default=None, + description=( + "Map rotation in degrees, range -180 to 180. Honored only by arctic/" + "antarctic projections; ignored by geographic." + ), + ) + + compare_active: bool | None = Field( + default=None, + description=( + "Activates Worldview's compare mode. Tri-state: None (default) — compare " + "OFF, no compare params emitted, all other compare_* args silently ignored. " + "True — compare ON with the A state shown as active. False — compare ON " + "with the B state shown as active. When set (True or False), compare_layers " + "is REQUIRED." + ), + ) + compare_layers: list[LayerSpec] | None = Field( + default=None, + description=( + "LayerSpec instances for the B state, same shape as `layers`. REQUIRED when compare_active is not None." + ), + ) + compare_time: str | date | datetime | None = Field( + default=None, + description=( + "Time for the B state, same accepted forms as `time`. Optional even when " + "compare is on; if omitted, the B state uses the same time as the A state." + ), + ) + compare_mode: Literal["swipe", "spy", "opacity"] = Field( + default="swipe", + description=( + "Comparison style. 'swipe' (vertical swiper between A and B), 'spy' " + "(lens-style hover view of B over A), or 'opacity' (A overlaid on B with " + "adjustable opacity). Only consulted when compare is active." + ), + ) + compare_value: int = Field( + default=50, + ge=0, + le=100, + description=( + "Position of the swiper or value of the opacity overlay, integer 0–100. " + "Only consulted when compare is active." + ), + ) + + chart_active: bool = Field( + default=False, + description=( + "Activates Worldview's charting mode (time-series of regional statistics " + "over a drawn area). False (default) — charting OFF, no chart params " + "emitted, all other chart_* args silently ignored. True — charting ON. " + "When True, chart_layer is REQUIRED." + ), + ) + chart_layer: str | None = Field( + default=None, + description=("GIBS layer ID to chart. Charting supports one layer at a time. REQUIRED when chart_active=True."), + ) + chart_area: tuple[float, float, float, float] | None = Field( + default=None, + description=( + "Area-of-interest for the chart, as (x1, y1, x2, y2) in the same coordinate " + "system as `bbox`. Statistics are computed over this region." + ), + ) + chart_time_start: str | date | datetime | None = Field( + default=None, + description="Start of the chart's time range, same accepted forms as `time`.", + ) + chart_time_end: str | date | datetime | None = Field( + default=None, + description="End of the chart's time range.", + ) + chart_autoload: bool = Field( + default=False, + description=( + "If True, the chart computes and renders the moment the link is opened. " + "Default False (user must click 'Generate Chart' in the Worldview UI)." + ), + ) + + @model_validator(mode="after") + def _enforce_feature_gates(self) -> Self: + if self.compare_active is not None and self.compare_layers is None: + raise ValueError( + "compare_active is set, so compare_layers is required " + "(cannot enable compare mode without a B-state layer list)" + ) + if self.chart_active and self.chart_layer is None: + raise ValueError( + "chart_active is True, so chart_layer is required (cannot enable charting without a layer to chart)" + ) + return self + + +class WorldviewPermalinkOutputSchema(OutputSchema): + """Output schema for the Worldview Permalink Tool.""" + + url: str = Field( + ..., + description="A complete NASA Worldview permalink URL that opens the map at the requested state.", + ) + + +@mcp_tool +class WorldviewPermalinkTool(BaseTool[WorldviewPermalinkInputSchema, WorldviewPermalinkOutputSchema]): + """ + Build a NASA Worldview permalink URL. + + Generates a deep link to NASA Worldview (https://worldview.earthdata.nasa.gov) + that opens the interactive map at a specific layer configuration, time, + viewport, and (optionally) comparison or charting state. No I/O — pure URL + string assembly. + + Use this tool after a dataset has been confirmed with the user, to produce + the visualization link the user will open. The IESO Worldview agent calls + this in its "Visualization Construction" and "Analysis Support" steps. + + Required: + - layers: at least one LayerSpec (GIBS layer ID + optional rendering modifiers) + + Optional viewport / time: + - projection, time, bbox, rotation — omit any to inherit Worldview's defaults + + Optional feature blocks (each gated by an _active flag; the rest of the + block is silently ignored when the gate is off): + - Comparison: set compare_active=True (A side) or False (B side) and + provide compare_layers; optionally compare_time / compare_mode / + compare_value. TODO: make this a boolean on and off. + - Charting: set chart_active=True and provide chart_layer; optionally + chart_area / chart_time_start / chart_time_end / chart_autoload. + """ + + input_schema = WorldviewPermalinkInputSchema + output_schema = WorldviewPermalinkOutputSchema + config_schema = WorldviewPermalinkToolConfig + + async def _arun(self, params: WorldviewPermalinkInputSchema) -> WorldviewPermalinkOutputSchema: + url = build_worldview_permalink( + base_url=self.config.base_url, + layers=params.layers, + projection=params.projection, + time=params.time, + bbox=params.bbox, + rotation=params.rotation, + compare_active=params.compare_active, + compare_layers=params.compare_layers, + compare_time=params.compare_time, + compare_mode=params.compare_mode, + compare_value=params.compare_value, + chart_active=params.chart_active, + chart_layer=params.chart_layer, + chart_area=params.chart_area, + chart_time_start=params.chart_time_start, + chart_time_end=params.chart_time_end, + chart_autoload=params.chart_autoload, + ) + # validate the url, throw proper error + return WorldviewPermalinkOutputSchema(url=url) diff --git a/tests/tools/worldview/test_permalink.py b/tests/tools/worldview/test_permalink.py new file mode 100644 index 0000000..4296ebe --- /dev/null +++ b/tests/tools/worldview/test_permalink.py @@ -0,0 +1,104 @@ +"""Unit tests for the WorldviewPermalinkTool wrapper.""" + +import pytest +from pydantic import ValidationError + +from akd_ext.tools.worldview import ( + LayerSpec, + WorldviewPermalinkInputSchema, + WorldviewPermalinkOutputSchema, + WorldviewPermalinkTool, +) + + +class TestWorldviewPermalinkTool: + """Tool-level behaviour: input schema validation and _arun delegation.""" + + async def test_arun_returns_output_schema_with_url(self): + tool = WorldviewPermalinkTool() + result = await tool.arun( + WorldviewPermalinkInputSchema( + layers=[LayerSpec(id="MODIS_Terra_CorrectedReflectance_TrueColor")], + time="2025-09-15", + bbox=(-125, 32, -114, 42), + ) + ) + assert isinstance(result, WorldviewPermalinkOutputSchema) + assert result.url.startswith("https://worldview.earthdata.nasa.gov/?") + assert "l=MODIS_Terra_CorrectedReflectance_TrueColor" in result.url + assert "t=2025-09-15" in result.url + assert "v=-125,32,-114,42" in result.url + + async def test_arun_compare_block(self): + tool = WorldviewPermalinkTool() + result = await tool.arun( + WorldviewPermalinkInputSchema( + layers=[LayerSpec(id="L_A")], + compare_active=True, + compare_layers=[LayerSpec(id="L_B")], + compare_time="2025-09-14", + compare_mode="spy", + compare_value=70, + ) + ) + assert "ca=true" in result.url + assert "cm=spy" in result.url + assert "cv=70" in result.url + assert "l1=L_B" in result.url + assert "t1=2025-09-14" in result.url + + async def test_arun_chart_block(self): + tool = WorldviewPermalinkTool() + result = await tool.arun( + WorldviewPermalinkInputSchema( + layers=[LayerSpec(id="L")], + chart_active=True, + chart_layer="L_CHART", + chart_area=(-125, 32, -114, 42), + chart_time_start="2025-09-01", + chart_time_end="2025-09-30", + chart_autoload=True, + ) + ) + assert "cha=true" in result.url + assert "chl=L_CHART" in result.url + assert "chc=-125,32,-114,42" in result.url + assert "cht=2025-09-01" in result.url + assert "cht2=2025-09-30" in result.url + assert "chch=true" in result.url + + +class TestSchemaValidation: + """Cross-field gate constraints enforced by model_validator.""" + + def test_compare_active_without_compare_layers_rejected(self): + with pytest.raises(ValidationError, match="compare_layers is required"): + WorldviewPermalinkInputSchema( + layers=[LayerSpec(id="L")], + compare_active=True, + compare_layers=None, + ) + + def test_chart_active_without_chart_layer_rejected(self): + with pytest.raises(ValidationError, match="chart_layer is required"): + WorldviewPermalinkInputSchema( + layers=[LayerSpec(id="L")], + chart_active=True, + chart_layer=None, + ) + + def test_compare_value_out_of_range_rejected(self): + with pytest.raises(ValidationError): + WorldviewPermalinkInputSchema( + layers=[LayerSpec(id="L")], + compare_active=True, + compare_layers=[LayerSpec(id="LB")], + compare_value=150, + ) + + def test_minimal_input_validates(self): + # Just layers — no compare, no chart, no extras + schema = WorldviewPermalinkInputSchema(layers=[LayerSpec(id="L")]) + assert schema.compare_active is None + assert schema.chart_active is False + assert schema.projection == "geographic" From 2f91653dedfb05ca7b931cffbe275d7976e526f0 Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Mon, 4 May 2026 16:00:04 -0500 Subject: [PATCH 06/16] when no datetime provided, defer to yesterday date for a full renderable layer --- akd_ext/tools/worldview/permalink.py | 9 ++++++--- akd_ext/tools/worldview/utils.py | 18 ++++++++++++------ tests/tools/worldview/test_utils.py | 7 +++++-- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/akd_ext/tools/worldview/permalink.py b/akd_ext/tools/worldview/permalink.py index 2d6310a..0794b35 100644 --- a/akd_ext/tools/worldview/permalink.py +++ b/akd_ext/tools/worldview/permalink.py @@ -51,9 +51,12 @@ class WorldviewPermalinkInputSchema(InputSchema): "Map time. Accepts a date (daily resolution), a datetime (subdaily, " "normalised to UTC), or a string in any reasonable date/datetime format — " "ISO 8601, 'Sep 15, 2025', '2025/09/15', TZ-aware forms, etc. (parsed via " - "dateutil). If None, Worldview defaults to today. Note: ambiguous " - "slash-separated strings like '01/02/2025' are interpreted as month/day/year " - "by default; pass an unambiguous form ('2025-01-02') if the order matters." + "dateutil). If None, defaults to yesterday (UTC) — Worldview's own 'today' " + "default can show partially-rendered scenes because daily MODIS/VIIRS data " + "is still being ingested into GIBS; yesterday guarantees a fully-rendered " + "scene. Note: ambiguous slash-separated strings like '01/02/2025' are " + "interpreted as month/day/year by default; pass an unambiguous form " + "('2025-01-02') if the order matters." ), ) bbox: tuple[float, float, float, float] | None = Field( diff --git a/akd_ext/tools/worldview/utils.py b/akd_ext/tools/worldview/utils.py index 25c8f6b..7440dea 100644 --- a/akd_ext/tools/worldview/utils.py +++ b/akd_ext/tools/worldview/utils.py @@ -1,4 +1,4 @@ -from datetime import date, datetime, timezone +from datetime import date, datetime, timedelta, timezone from typing import Literal from urllib.parse import urlencode @@ -148,11 +148,15 @@ def build_worldview_permalink( time: Map time. Accepts a date (daily resolution), a datetime (subdaily, normalised to UTC), or a string in any reasonable date/datetime format — ISO 8601, 'Sep 15, 2025', '2025/09/15', - TZ-aware forms, etc. (parsed via dateutil). If None, Worldview - defaults to today. Note: ambiguous slash-separated strings like - '01/02/2025' are interpreted as month/day/year by default; - pass an unambiguous form ('2025-01-02') or a date object if - the order matters. + TZ-aware forms, etc. (parsed via dateutil). If None, defaults + to yesterday (UTC) — Worldview's own "today" default can show + partially-rendered scenes because daily MODIS/VIIRS data is + still being ingested into GIBS; yesterday guarantees a + fully-rendered scene. Pass an explicit `date.today()` if the + partial today behaviour is what you want. Note: ambiguous + slash-separated strings like '01/02/2025' are interpreted as + month/day/year by default; pass an unambiguous form + ('2025-01-02') or a date object if the order matters. bbox: Map viewport extent as (west, south, east, north). Degrees for the geographic projection; projected meters for arctic/ antarctic. If None, Worldview opens at its default global extent. @@ -250,6 +254,8 @@ def build_worldview_permalink( ) params["l1"] = ",".join(_format_layer(s) for s in compare_layers) + if time is None: + time = (datetime.now(timezone.utc) - timedelta(days=1)).date() if (formatted := _format_time(time)) is not None: params["t"] = formatted diff --git a/tests/tools/worldview/test_utils.py b/tests/tools/worldview/test_utils.py index 43d5672..f878ad3 100644 --- a/tests/tools/worldview/test_utils.py +++ b/tests/tools/worldview/test_utils.py @@ -121,9 +121,12 @@ def test_unparseable_string_raises(self): with pytest.raises(ValueError, match="banana"): build_worldview_permalink(layers=[LayerSpec(id="L")], time="banana") - def test_none_omits_param(self): + def test_none_defaults_to_yesterday_utc(self): + # Function defaults `time` to yesterday (UTC) to avoid Worldview's + # partially-rendered "today" scenes. url = build_worldview_permalink(layers=[LayerSpec(id="L")]) - assert "&t=" not in url and not url.endswith("?t=") + yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).date().isoformat() + assert f"t={yesterday}" in url class TestCoreParams: From 1cb8178f771ff370480dab531c6706db00147efd Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Mon, 4 May 2026 16:07:02 -0500 Subject: [PATCH 07/16] embed mode true on each generated url --- akd_ext/tools/worldview/utils.py | 5 +++++ tests/tools/worldview/test_utils.py | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/akd_ext/tools/worldview/utils.py b/akd_ext/tools/worldview/utils.py index 7440dea..0ce75de 100644 --- a/akd_ext/tools/worldview/utils.py +++ b/akd_ext/tools/worldview/utils.py @@ -130,6 +130,9 @@ def build_worldview_permalink( layer configuration, time, viewport, and (optionally) compare or charting state. Pure URL string assembly — no I/O. + Every URL is emitted in embed mode (`em=true`), which strips Worldview's + side panels and header chrome for clean rendering in chat/iframe contexts. + This function is the underlying implementation for a future WorldviewPermalinkTool; parameter descriptions here are written as agent-facing guidance and will map 1:1 to Pydantic Field descriptions on @@ -291,6 +294,8 @@ def build_worldview_permalink( params["cht2"] = formatted params["chch"] = "true" if chart_autoload else "false" + params["em"] = "true" + return f"{base_url}?{urlencode(params, safe=',()=:')}" diff --git a/tests/tools/worldview/test_utils.py b/tests/tools/worldview/test_utils.py index f878ad3..0d57dcf 100644 --- a/tests/tools/worldview/test_utils.py +++ b/tests/tools/worldview/test_utils.py @@ -156,6 +156,12 @@ def test_no_rotation_omits_param(self): url = build_worldview_permalink(layers=[LayerSpec(id="L")]) assert "r=" not in query_string(url) + def test_embed_mode_always_emitted(self): + # Embed mode is unconditional — em=true must appear on every URL so + # the link renders cleanly in chat / iframe contexts. + url = build_worldview_permalink(layers=[LayerSpec(id="L")]) + assert "em=true" in url + class TestCompareMode: """compare_active gate behaviour.""" From 1f9a8a544791ae5afe10eaae81b09fefea1cd63f Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Mon, 4 May 2026 17:26:50 -0500 Subject: [PATCH 08/16] add base layers and overlays when not present --- akd_ext/tools/worldview/utils.py | 64 ++++++++++++- tests/tools/worldview/test_permalink.py | 3 +- tests/tools/worldview/test_utils.py | 116 +++++++++++++++++++++--- 3 files changed, 165 insertions(+), 18 deletions(-) diff --git a/akd_ext/tools/worldview/utils.py b/akd_ext/tools/worldview/utils.py index 0ce75de..f2d07d4 100644 --- a/akd_ext/tools/worldview/utils.py +++ b/akd_ext/tools/worldview/utils.py @@ -5,6 +5,18 @@ from dateutil import parser as date_parser from pydantic import BaseModel, Field +BASE_LAYERS: frozenset[str] = frozenset( + { + "MODIS_Terra_CorrectedReflectance_TrueColor", + "MODIS_Aqua_CorrectedReflectance_TrueColor", + "VIIRS_SNPP_CorrectedReflectance_TrueColor", + "VIIRS_NOAA20_CorrectedReflectance_TrueColor", + "VIIRS_NOAA21_CorrectedReflectance_TrueColor", + } +) +DEFAULT_BASE_LAYER: str = "MODIS_Terra_CorrectedReflectance_TrueColor" +DEFAULT_REFERENCE_OVERLAYS: tuple[str, ...] = ("Coastlines_15m", "Reference_Features_15m") + class LayerSpec(BaseModel): """A single Worldview layer, optionally with rendering modifiers. @@ -85,6 +97,35 @@ def _format_layer(spec: LayerSpec) -> str: return f"{spec.id}({','.join(tokens)})" +def _apply_layer_preprocessing(layers: list[LayerSpec]) -> list[LayerSpec]: + """Pre-process a layer list before URL emission. Applied unconditionally. + + Three steps: + 1. Prepend the default base layer if none of the supplied layers' ids are in + BASE_LAYERS. Load-bearing — Worldview shows a black background when + l= contains only overlays. + 2. Append default reference overlays (Coastlines_15m, Reference_Features_15m) + that aren't already present. Provides land/water clarity + political borders. + 3. Canonical reorder: baselayers first, overlays after; user-supplied order + preserved within each partition. + + Returns a new list; the input is not mutated. + """ + result = list(layers) + + if not any(layer.id in BASE_LAYERS for layer in result): + result = [LayerSpec(id=DEFAULT_BASE_LAYER), *result] + + existing_ids = {layer.id for layer in result} + for ref_id in DEFAULT_REFERENCE_OVERLAYS: + if ref_id not in existing_ids: + result.append(LayerSpec(id=ref_id)) + + baselayers = [layer for layer in result if layer.id in BASE_LAYERS] + overlays = [layer for layer in result if layer.id not in BASE_LAYERS] + return [*baselayers, *overlays] + + def _format_time(t: str | date | datetime | None) -> str | None: if t is None: return None @@ -139,10 +180,19 @@ def build_worldview_permalink( the tool's input schema. Args: - layers: One or more LayerSpec instances, in render order (top of - stack last). Each LayerSpec carries a GIBS layer ID plus - optional per-layer modifiers (hidden, opacity, palettes, style, - min/max, squash). REQUIRED — at least one layer must be supplied. + layers: One or more LayerSpec instances. Each LayerSpec carries a + GIBS layer ID plus optional per-layer modifiers (hidden, opacity, + palettes, style, min/max, squash). REQUIRED — at least one layer + must be supplied. Always pre-processed before URL emission: + (1) a default base layer (MODIS Terra True Color) is prepended + if none of the supplied layers' ids are in BASE_LAYERS — this + is load-bearing because Worldview shows a black background + otherwise; (2) the default reference overlays Coastlines_15m + and Reference_Features_15m are appended if not already present; + (3) the final list is canonically reordered (baselayers first, + overlays after) with user-supplied order preserved within each + partition. Pre-processing also applies to compare_layers when + compare is active. projection: Map projection. Use 'geographic' for global Mercator (the default), 'arctic' for north polar stereographic, or 'antarctic' for south polar stereographic. @@ -176,7 +226,9 @@ def build_worldview_permalink( → ca=false). compare_layers: LayerSpec instances for the B state, same shape as `layers`. REQUIRED when compare_active is not None; raises - ValueError if missing. + ValueError if missing. Subject to the same layer pre-processing + described under `layers` (default base + reference overlays + auto-added; canonical reorder). compare_time: Time for the B state, same accepted forms as `time`. Optional even when compare is on; if omitted, the B state uses the same time as the A state. @@ -247,6 +299,7 @@ def build_worldview_permalink( """ params: dict[str, str] = {} + layers = _apply_layer_preprocessing(layers) params["l"] = ",".join(_format_layer(s) for s in layers) if compare_active is not None: @@ -255,6 +308,7 @@ def build_worldview_permalink( "compare_active is set, so compare_layers is required " "(cannot enable compare mode without a B-state layer list)" ) + compare_layers = _apply_layer_preprocessing(compare_layers) params["l1"] = ",".join(_format_layer(s) for s in compare_layers) if time is None: diff --git a/tests/tools/worldview/test_permalink.py b/tests/tools/worldview/test_permalink.py index 4296ebe..6fb70ec 100644 --- a/tests/tools/worldview/test_permalink.py +++ b/tests/tools/worldview/test_permalink.py @@ -44,7 +44,8 @@ async def test_arun_compare_block(self): assert "ca=true" in result.url assert "cm=spy" in result.url assert "cv=70" in result.url - assert "l1=L_B" in result.url + # B-state list is pre-processed: base prepended, refs appended. + assert ",L_B," in result.url assert "t1=2025-09-14" in result.url async def test_arun_chart_block(self): diff --git a/tests/tools/worldview/test_utils.py b/tests/tools/worldview/test_utils.py index 0d57dcf..dc457ff 100644 --- a/tests/tools/worldview/test_utils.py +++ b/tests/tools/worldview/test_utils.py @@ -24,33 +24,36 @@ def test_layerspec_id_is_required(self): LayerSpec(opacity=0.5) # type: ignore[call-arg] def test_all_defaults_renders_bare_id(self): + # LAYER_X is a non-base id, so pre-processing prepends a base and appends + # default reference overlays. LAYER_X itself still renders bare (no parens). url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X")]) - assert "l=LAYER_X" in url + assert ",LAYER_X," in query_string(url) + # No layer in the resulting list has modifiers, so no parens anywhere. assert "(" not in query_string(url) def test_hidden(self): url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", hidden=True)]) - assert "l=LAYER_X(hidden)" in url + assert "LAYER_X(hidden)" in url def test_opacity(self): url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", opacity=0.7)]) - assert "l=LAYER_X(opacity=0.7)" in url + assert "LAYER_X(opacity=0.7)" in url def test_palettes(self): url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", palettes=["red", "blue"])]) - assert "l=LAYER_X(palettes=red,blue)" in url + assert "LAYER_X(palettes=red,blue)" in url def test_style(self): url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", style="vector_style")]) - assert "l=LAYER_X(style=vector_style)" in url + assert "LAYER_X(style=vector_style)" in url def test_min_max(self): url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", min=0, max=100)]) - assert "l=LAYER_X(min=0,max=100)" in url + assert "LAYER_X(min=0,max=100)" in url def test_squash(self): url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", squash=True)]) - assert "l=LAYER_X(squash)" in url + assert "LAYER_X(squash)" in url def test_multiple_modifiers_use_documented_token_order(self): # _format_layer order: hidden, opacity, palettes, style, min, max, squash @@ -65,11 +68,13 @@ def test_multiple_modifiers_use_documented_token_order(self): ) ] ) - assert "l=LAYER_X(hidden,opacity=0.5,palettes=red,squash)" in url + assert "LAYER_X(hidden,opacity=0.5,palettes=red,squash)" in url def test_multiple_layers_comma_joined(self): + # Both LAYER_A and LAYER_B are non-base; canonical reorder keeps user-supplied + # order within the overlay partition. url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_A"), LayerSpec(id="LAYER_B", opacity=0.5)]) - assert "l=LAYER_A,LAYER_B(opacity=0.5)" in url + assert "LAYER_A,LAYER_B(opacity=0.5)" in url class TestTimeFormatting: @@ -176,7 +181,8 @@ def test_gate_on_a_side_emits_full_block(self): compare_mode="swipe", compare_value=50, ) - assert "l1=L_B" in url + # B-state list is also pre-processed: base prepended, refs appended. + assert ",L_B," in url assert "t1=2025-09-14" in url assert "ca=true" in url assert "cm=swipe" in url @@ -189,7 +195,7 @@ def test_gate_on_b_side_emits_ca_false(self): compare_layers=[LayerSpec(id="L_B")], ) assert "ca=false" in url - assert "l1=L_B" in url + assert ",L_B," in url def test_gate_off_short_circuits_stray_args(self): url = build_worldview_permalink( @@ -222,7 +228,7 @@ def test_compare_time_is_optional_when_gate_on(self): compare_layers=[LayerSpec(id="L_B")], ) assert "ca=true" in url - assert "l1=L_B" in url + assert ",L_B," in url assert "t1=" not in query_string(url) @@ -278,3 +284,89 @@ def test_chart_autoload_default_false(self): chart_layer="L_CHART", ) assert "chch=false" in url + + +class TestLayerPreprocessing: + """Unconditional pre-processing: auto-add base, auto-append default reference + overlays, canonical reorder. Same logic applies to compare_layers.""" + + def _layer_list(self, url: str, key: str = "l") -> list[str]: + """Extract the comma-separated layer ids from `?=...&...`.""" + return url.split(f"{key}=")[1].split("&")[0].split(",") + + @pytest.mark.parametrize( + "base_id", + [ + "MODIS_Terra_CorrectedReflectance_TrueColor", + "MODIS_Aqua_CorrectedReflectance_TrueColor", + "VIIRS_SNPP_CorrectedReflectance_TrueColor", + "VIIRS_NOAA20_CorrectedReflectance_TrueColor", + "VIIRS_NOAA21_CorrectedReflectance_TrueColor", + ], + ) + def test_known_base_layer_is_recognised(self, base_id): + # When the user supplies any known base, no second base is auto-prepended. + url = build_worldview_permalink(layers=[LayerSpec(id=base_id)]) + ids = self._layer_list(url) + bases_in_url = [ + i + for i in ids + if i.split("(")[0] + in { + "MODIS_Terra_CorrectedReflectance_TrueColor", + "MODIS_Aqua_CorrectedReflectance_TrueColor", + "VIIRS_SNPP_CorrectedReflectance_TrueColor", + "VIIRS_NOAA20_CorrectedReflectance_TrueColor", + "VIIRS_NOAA21_CorrectedReflectance_TrueColor", + } + ] + assert bases_in_url == [base_id] + + def test_auto_add_base_when_missing(self): + # MODIS_Aqua_AOD is an overlay — pre-processor must prepend the default base. + url = build_worldview_permalink(layers=[LayerSpec(id="MODIS_Aqua_AOD")]) + ids = self._layer_list(url) + assert ids[0] == "MODIS_Terra_CorrectedReflectance_TrueColor" + assert "MODIS_Aqua_AOD" in ids + + def test_auto_append_default_reference_overlays(self): + url = build_worldview_permalink(layers=[LayerSpec(id="MODIS_Aqua_AOD")]) + assert "Coastlines_15m" in url + assert "Reference_Features_15m" in url + + def test_partial_reference_overlay_already_present_only_missing_appended(self): + # User supplies Coastlines_15m themselves; pre-processor must not duplicate it, + # but must still append the missing Reference_Features_15m. + url = build_worldview_permalink(layers=[LayerSpec(id="MODIS_Aqua_AOD"), LayerSpec(id="Coastlines_15m")]) + ids = self._layer_list(url) + assert ids.count("Coastlines_15m") == 1 + assert "Reference_Features_15m" in ids + + def test_canonical_reorder_baselayers_first(self): + # User supplies overlay before base; pre-processor moves base to front and + # preserves user-supplied order within the overlay partition. + url = build_worldview_permalink( + layers=[ + LayerSpec(id="MODIS_Aqua_AOD"), # overlay + LayerSpec(id="VIIRS_NOAA21_CorrectedReflectance_TrueColor"), # base + LayerSpec(id="MODIS_Terra_AOD"), # overlay + ] + ) + ids = self._layer_list(url) + assert ids[0] == "VIIRS_NOAA21_CorrectedReflectance_TrueColor" + assert ids.index("MODIS_Aqua_AOD") < ids.index("MODIS_Terra_AOD") + assert "Coastlines_15m" in ids + assert "Reference_Features_15m" in ids + + def test_compare_layers_also_get_pre_processing(self): + # compare_layers (B-state) gets the same pre-processing as `layers`. + url = build_worldview_permalink( + layers=[LayerSpec(id="L_A")], + compare_active=True, + compare_layers=[LayerSpec(id="L_B")], + ) + b_ids = self._layer_list(url, key="l1") + assert b_ids[0] == "MODIS_Terra_CorrectedReflectance_TrueColor" + assert "L_B" in b_ids + assert "Coastlines_15m" in b_ids + assert "Reference_Features_15m" in b_ids From 787d0abb3506f71a96b4f8d312dd546e12cb0186 Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Tue, 5 May 2026 12:13:36 -0500 Subject: [PATCH 09/16] refactor to make code dry and sensible utils --- akd_ext/tools/worldview/__init__.py | 3 +- akd_ext/tools/worldview/permalink.py | 269 +++++++++++-- akd_ext/tools/worldview/utils.py | 380 ------------------ ...st_utils.py => test_permalink_assembly.py} | 110 +++-- 4 files changed, 293 insertions(+), 469 deletions(-) delete mode 100644 akd_ext/tools/worldview/utils.py rename tests/tools/worldview/{test_utils.py => test_permalink_assembly.py} (76%) diff --git a/akd_ext/tools/worldview/__init__.py b/akd_ext/tools/worldview/__init__.py index 2de5088..483b5f9 100644 --- a/akd_ext/tools/worldview/__init__.py +++ b/akd_ext/tools/worldview/__init__.py @@ -1,14 +1,13 @@ from akd_ext.tools.worldview.permalink import ( + LayerSpec, WorldviewPermalinkInputSchema, WorldviewPermalinkOutputSchema, WorldviewPermalinkTool, ) -from akd_ext.tools.worldview.utils import LayerSpec, build_worldview_permalink __all__ = [ "LayerSpec", "WorldviewPermalinkInputSchema", "WorldviewPermalinkOutputSchema", "WorldviewPermalinkTool", - "build_worldview_permalink", ] diff --git a/akd_ext/tools/worldview/permalink.py b/akd_ext/tools/worldview/permalink.py index 0794b35..5bf6377 100644 --- a/akd_ext/tools/worldview/permalink.py +++ b/akd_ext/tools/worldview/permalink.py @@ -1,27 +1,92 @@ """AKD Tool for the NASA Worldview permalink builder. -Wraps `build_worldview_permalink` from `utils.py` as a `BaseTool`. Field descriptions on the input -schema are written as agent-facing guidance and mirror the docstring of the -underlying function. +URL-assembly helpers and the public `build_url` live as static/classmethods on the tool class. +Field descriptions on the input schema are written as agent-facing guidance. """ import os -from datetime import date, datetime +from datetime import date, datetime, timedelta, timezone from typing import Literal, Self +from urllib.parse import urlencode from akd._base import InputSchema, OutputSchema from akd.tools import BaseTool, BaseToolConfig -from pydantic import Field, model_validator +from dateutil import parser as date_parser +from pydantic import BaseModel, Field, model_validator from akd_ext.mcp import mcp_tool -from akd_ext.tools.worldview.utils import LayerSpec, build_worldview_permalink + +DEFAULT_BASE_URL = "https://worldview.earthdata.nasa.gov/" + +BASE_LAYERS: tuple[str, ...] = ( + "MODIS_Terra_CorrectedReflectance_TrueColor", + "MODIS_Aqua_CorrectedReflectance_TrueColor", + "VIIRS_SNPP_CorrectedReflectance_TrueColor", + "VIIRS_NOAA20_CorrectedReflectance_TrueColor", + "VIIRS_NOAA21_CorrectedReflectance_TrueColor", +) +BASE_LAYERS_SET: frozenset[str] = frozenset(BASE_LAYERS) +DEFAULT_BASE_LAYER: str = BASE_LAYERS[0] +DEFAULT_REFERENCE_OVERLAYS: tuple[str, ...] = ("Coastlines_15m", "Reference_Features_15m") + + +class LayerSpec(BaseModel): + """A single Worldview layer, optionally with rendering modifiers. + + Field defaults match Worldview's own defaults; omitted fields are not + emitted into the URL. + """ + + id: str = Field( + ..., + description=( + "GIBS layer identifier (e.g. 'MODIS_Terra_CorrectedReflectance_TrueColor', " + "'VIIRS_SNPP_AOD'). Stable strings published by NASA's GIBS service." + ), + ) + hidden: bool = Field( + default=False, + description=( + "If True, the layer is included in the layer stack but rendered invisibly. " + "Useful for pre-loading toggleable layers without rebuilding the link." + ), + ) + opacity: float | None = Field( + default=None, + description="Layer opacity, 0.0 (fully transparent) to 1.0 (fully opaque). None for full opacity.", + ) + palettes: list[str] | None = Field( + default=None, + description=( + "Custom palette IDs to apply, in order. Only meaningful for raster layers that support palette swapping." + ), + ) + style: str | None = Field( + default=None, + description="Vector style ID. Only meaningful for vector layers.", + ) + min: float | None = Field( + default=None, + description="Lower bound of the palette/data range. Set together with `max` to clamp the visible range.", + ) + max: float | None = Field( + default=None, + description="Upper bound of the palette/data range.", + ) + squash: bool = Field( + default=False, + description=( + "If True, the palette is squashed to the designated min/max values " + "rather than spanning the layer's full data range." + ), + ) class WorldviewPermalinkToolConfig(BaseToolConfig): """Configuration for the WorldviewPermalinkTool Tool.""" base_url: str = Field( - default=os.getenv("WORLDVIEW_BASE_URL", "https://worldview.earthdata.nasa.gov/"), + default=os.getenv("WORLDVIEW_BASE_URL", DEFAULT_BASE_URL), description="Base URL for the NASA WORLDVIEW", ) @@ -186,8 +251,9 @@ class WorldviewPermalinkTool(BaseTool[WorldviewPermalinkInputSchema, WorldviewPe string assembly. Use this tool after a dataset has been confirmed with the user, to produce - the visualization link the user will open. The IESO Worldview agent calls - this in its "Visualization Construction" and "Analysis Support" steps. + the visualization link the user will open. + p.s. The IESO Worldview agent calls this in its "Visualization Construction" and + "Analysis Support" steps. Required: - layers: at least one LayerSpec (GIBS layer ID + optional rendering modifiers) @@ -199,7 +265,7 @@ class WorldviewPermalinkTool(BaseTool[WorldviewPermalinkInputSchema, WorldviewPe block is silently ignored when the gate is off): - Comparison: set compare_active=True (A side) or False (B side) and provide compare_layers; optionally compare_time / compare_mode / - compare_value. TODO: make this a boolean on and off. + compare_value. - Charting: set chart_active=True and provide chart_layer; optionally chart_area / chart_time_start / chart_time_end / chart_autoload. """ @@ -209,24 +275,167 @@ class WorldviewPermalinkTool(BaseTool[WorldviewPermalinkInputSchema, WorldviewPe config_schema = WorldviewPermalinkToolConfig async def _arun(self, params: WorldviewPermalinkInputSchema) -> WorldviewPermalinkOutputSchema: - url = build_worldview_permalink( - base_url=self.config.base_url, - layers=params.layers, - projection=params.projection, - time=params.time, - bbox=params.bbox, - rotation=params.rotation, - compare_active=params.compare_active, - compare_layers=params.compare_layers, - compare_time=params.compare_time, - compare_mode=params.compare_mode, - compare_value=params.compare_value, - chart_active=params.chart_active, - chart_layer=params.chart_layer, - chart_area=params.chart_area, - chart_time_start=params.chart_time_start, - chart_time_end=params.chart_time_end, - chart_autoload=params.chart_autoload, + return WorldviewPermalinkOutputSchema(url=self.build_url(params, self.config.base_url)) + + @classmethod + def build_url(cls, params: WorldviewPermalinkInputSchema, base_url: str = DEFAULT_BASE_URL) -> str: + """Pure URL-string assembly from a validated input schema. No I/O. + + The schema's `_enforce_feature_gates` validator guarantees the + compare/chart not-None invariants before this method runs, so the body + does not re-check them. + """ + out: dict[str, str] = {} + + layers = cls._apply_layer_preprocessing(params.layers) + out["l"] = ",".join(cls._format_layer(s) for s in layers) + + if params.compare_active is not None: + assert params.compare_layers is not None + compare_layers = cls._apply_layer_preprocessing(params.compare_layers) + out["l1"] = ",".join(cls._format_layer(s) for s in compare_layers) + + time_value = params.time if params.time is not None else (datetime.now(timezone.utc) - timedelta(days=1)).date() + if (formatted := cls._format_time(time_value)) is not None: + out["t"] = formatted + + if params.compare_active is not None and (formatted := cls._format_time(params.compare_time)) is not None: + out["t1"] = formatted + + if params.bbox is not None: + out["v"] = ",".join(cls._fmt_num(x) for x in params.bbox) + + out["p"] = params.projection + + if params.rotation is not None: + out["r"] = cls._fmt_num(params.rotation) + + if params.compare_active is not None: + out["ca"] = "true" if params.compare_active else "false" + out["cm"] = params.compare_mode + out["cv"] = str(params.compare_value) + + if params.chart_active: + assert params.chart_layer is not None + out["cha"] = "true" + out["chl"] = params.chart_layer + if params.chart_area is not None: + out["chc"] = ",".join(cls._fmt_num(x) for x in params.chart_area) + if (formatted := cls._format_time(params.chart_time_start)) is not None: + out["cht"] = formatted + if (formatted := cls._format_time(params.chart_time_end)) is not None: + out["cht2"] = formatted + out["chch"] = "true" if params.chart_autoload else "false" + + out["em"] = "true" + return f"{base_url}?{urlencode(out, safe=',()=:')}" + + # ----------------------------------------------------------------------------- + # Utility static methods + # ----------------------------------------------------------------------------- + + @staticmethod + def _fmt_num(n: float | int) -> str: + if isinstance(n, float) and n.is_integer(): + return str(int(n)) + return str(n) + + @classmethod + def _format_layer(cls, spec: LayerSpec) -> str: + tokens: list[str] = [] + if spec.hidden: + tokens.append("hidden") + if spec.opacity is not None: + tokens.append(f"opacity={cls._fmt_num(spec.opacity)}") + if spec.palettes: + tokens.append(f"palettes={','.join(spec.palettes)}") + if spec.style is not None: + tokens.append(f"style={spec.style}") + if spec.min is not None: + tokens.append(f"min={cls._fmt_num(spec.min)}") + if spec.max is not None: + tokens.append(f"max={cls._fmt_num(spec.max)}") + if spec.squash: + tokens.append("squash") + if not tokens: + return spec.id + return f"{spec.id}({','.join(tokens)})" + + @staticmethod + def _apply_layer_preprocessing(layers: list[LayerSpec]) -> list[LayerSpec]: + """Pre-process a layer list before URL emission. Applied unconditionally. + + Three steps: + 1. Prepend the default base layer if none of the supplied layers' ids are in + BASE_LAYERS_SET. Load-bearing — Worldview shows a black background when + l= contains only overlays. + 2. Append default reference overlays (Coastlines_15m, Reference_Features_15m) + that aren't already present. Provides land/water clarity + political borders. + 3. Canonical reorder: baselayers first, overlays after; user-supplied order + preserved within each partition. + + Returns a new list; the input is not mutated. + """ + result = list(layers) + + if not any(layer.id in BASE_LAYERS_SET for layer in result): + result = [LayerSpec(id=DEFAULT_BASE_LAYER), *result] + + existing_ids = {layer.id for layer in result} + for ref_id in DEFAULT_REFERENCE_OVERLAYS: + if ref_id not in existing_ids: + result.append(LayerSpec(id=ref_id)) + + baselayers = [layer for layer in result if layer.id in BASE_LAYERS_SET] + overlays = [layer for layer in result if layer.id not in BASE_LAYERS_SET] + return [*baselayers, *overlays] + + @staticmethod + def _format_time(t: str | date | datetime | None) -> str | None: + if t is None: + return None + if isinstance(t, str): + try: + t = date_parser.parse(t) + except (ValueError, OverflowError) as e: + raise ValueError(f"Could not parse time {t!r}: {e}") from e + if isinstance(t, datetime): + if t.tzinfo is not None: + t = t.astimezone(timezone.utc) + if t.time() == datetime.min.time(): + return t.date().isoformat() + return t.strftime("%Y-%m-%dT%H:%M:%SZ") + if isinstance(t, date): + return t.isoformat() + raise TypeError(f"Unsupported time type: {type(t).__name__}") + + +if __name__ == "__main__": + core = WorldviewPermalinkTool.build_url( + WorldviewPermalinkInputSchema( + layers=[LayerSpec(id="MODIS_Terra_CorrectedReflectance_TrueColor")], + time="2025-09-15", + bbox=(-125, 32, -114, 42), ) - # validate the url, throw proper error - return WorldviewPermalinkOutputSchema(url=url) + ) + print("Core:", core) + + rich = WorldviewPermalinkTool.build_url( + WorldviewPermalinkInputSchema( + layers=[LayerSpec(id="MODIS_Terra_AOD", opacity=0.8)], + time="September 15, 2025", + bbox=(-125, 32, -114, 42), + compare_active=True, + compare_layers=[LayerSpec(id="MODIS_Aqua_AOD")], + compare_time="2025-09-14", + compare_mode="swipe", + compare_value=60, + chart_active=True, + chart_layer="MODIS_Terra_AOD", + chart_area=(-125, 32, -114, 42), + chart_time_start="2025-09-01", + chart_time_end="2025-09-30", + chart_autoload=True, + ) + ) + print("Rich:", rich) diff --git a/akd_ext/tools/worldview/utils.py b/akd_ext/tools/worldview/utils.py deleted file mode 100644 index f2d07d4..0000000 --- a/akd_ext/tools/worldview/utils.py +++ /dev/null @@ -1,380 +0,0 @@ -from datetime import date, datetime, timedelta, timezone -from typing import Literal -from urllib.parse import urlencode - -from dateutil import parser as date_parser -from pydantic import BaseModel, Field - -BASE_LAYERS: frozenset[str] = frozenset( - { - "MODIS_Terra_CorrectedReflectance_TrueColor", - "MODIS_Aqua_CorrectedReflectance_TrueColor", - "VIIRS_SNPP_CorrectedReflectance_TrueColor", - "VIIRS_NOAA20_CorrectedReflectance_TrueColor", - "VIIRS_NOAA21_CorrectedReflectance_TrueColor", - } -) -DEFAULT_BASE_LAYER: str = "MODIS_Terra_CorrectedReflectance_TrueColor" -DEFAULT_REFERENCE_OVERLAYS: tuple[str, ...] = ("Coastlines_15m", "Reference_Features_15m") - - -class LayerSpec(BaseModel): - """A single Worldview layer, optionally with rendering modifiers. - - Field defaults match Worldview's own defaults; omitted fields are not - emitted into the URL. - """ - - id: str = Field( - ..., - description=( - "GIBS layer identifier (e.g. 'MODIS_Terra_CorrectedReflectance_TrueColor', " - "'VIIRS_SNPP_AOD'). Stable strings published by NASA's GIBS service." - ), - ) - hidden: bool = Field( - default=False, - description=( - "If True, the layer is included in the layer stack but rendered invisibly. " - "Useful for pre-loading toggleable layers without rebuilding the link." - ), - ) - opacity: float | None = Field( - default=None, - description="Layer opacity, 0.0 (fully transparent) to 1.0 (fully opaque). None for full opacity.", - ) - palettes: list[str] | None = Field( - default=None, - description=( - "Custom palette IDs to apply, in order. Only meaningful for raster layers that support palette swapping." - ), - ) - style: str | None = Field( - default=None, - description="Vector style ID. Only meaningful for vector layers.", - ) - min: float | None = Field( - default=None, - description="Lower bound of the palette/data range. Set together with `max` to clamp the visible range.", - ) - max: float | None = Field( - default=None, - description="Upper bound of the palette/data range.", - ) - squash: bool = Field( - default=False, - description=( - "If True, the palette is squashed to the designated min/max values " - "rather than spanning the layer's full data range." - ), - ) - - -def _fmt_num(n: float | int) -> str: - if isinstance(n, float) and n.is_integer(): - return str(int(n)) - return str(n) - - -def _format_layer(spec: LayerSpec) -> str: - tokens: list[str] = [] - if spec.hidden: - tokens.append("hidden") - if spec.opacity is not None: - tokens.append(f"opacity={_fmt_num(spec.opacity)}") - if spec.palettes: - tokens.append(f"palettes={','.join(spec.palettes)}") - if spec.style is not None: - tokens.append(f"style={spec.style}") - if spec.min is not None: - tokens.append(f"min={_fmt_num(spec.min)}") - if spec.max is not None: - tokens.append(f"max={_fmt_num(spec.max)}") - if spec.squash: - tokens.append("squash") - if not tokens: - return spec.id - return f"{spec.id}({','.join(tokens)})" - - -def _apply_layer_preprocessing(layers: list[LayerSpec]) -> list[LayerSpec]: - """Pre-process a layer list before URL emission. Applied unconditionally. - - Three steps: - 1. Prepend the default base layer if none of the supplied layers' ids are in - BASE_LAYERS. Load-bearing — Worldview shows a black background when - l= contains only overlays. - 2. Append default reference overlays (Coastlines_15m, Reference_Features_15m) - that aren't already present. Provides land/water clarity + political borders. - 3. Canonical reorder: baselayers first, overlays after; user-supplied order - preserved within each partition. - - Returns a new list; the input is not mutated. - """ - result = list(layers) - - if not any(layer.id in BASE_LAYERS for layer in result): - result = [LayerSpec(id=DEFAULT_BASE_LAYER), *result] - - existing_ids = {layer.id for layer in result} - for ref_id in DEFAULT_REFERENCE_OVERLAYS: - if ref_id not in existing_ids: - result.append(LayerSpec(id=ref_id)) - - baselayers = [layer for layer in result if layer.id in BASE_LAYERS] - overlays = [layer for layer in result if layer.id not in BASE_LAYERS] - return [*baselayers, *overlays] - - -def _format_time(t: str | date | datetime | None) -> str | None: - if t is None: - return None - if isinstance(t, str): - try: - t = date_parser.parse(t) - except (ValueError, OverflowError) as e: - raise ValueError(f"Could not parse time {t!r}: {e}") from e - if isinstance(t, datetime): - if t.tzinfo is not None: - t = t.astimezone(timezone.utc) - if t.time() == datetime.min.time(): - return t.date().isoformat() - return t.strftime("%Y-%m-%dT%H:%M:%SZ") - if isinstance(t, date): - return t.isoformat() - raise TypeError(f"Unsupported time type: {type(t).__name__}") - - -def build_worldview_permalink( - layers: list[LayerSpec], - projection: Literal["geographic", "arctic", "antarctic"] = "geographic", - base_url: str = "https://worldview.earthdata.nasa.gov/", - time: str | date | datetime | None = None, - bbox: tuple[float, float, float, float] | None = None, - rotation: float | None = None, - *, - compare_active: bool | None = None, - compare_layers: list[LayerSpec] | None = None, - compare_time: str | date | datetime | None = None, - compare_mode: Literal["swipe", "spy", "opacity"] = "swipe", - compare_value: int = 50, - chart_active: bool = False, - chart_layer: str | None = None, - chart_area: tuple[float, float, float, float] | None = None, - chart_time_start: str | date | datetime | None = None, - chart_time_end: str | date | datetime | None = None, - chart_autoload: bool = False, -) -> str: - """Build a NASA Worldview permalink URL. - - Generates a deep link to NASA Worldview that opens the map at a specific - layer configuration, time, viewport, and (optionally) compare or charting - state. Pure URL string assembly — no I/O. - - Every URL is emitted in embed mode (`em=true`), which strips Worldview's - side panels and header chrome for clean rendering in chat/iframe contexts. - - This function is the underlying implementation for a future - WorldviewPermalinkTool; parameter descriptions here are written as - agent-facing guidance and will map 1:1 to Pydantic Field descriptions on - the tool's input schema. - - Args: - layers: One or more LayerSpec instances. Each LayerSpec carries a - GIBS layer ID plus optional per-layer modifiers (hidden, opacity, - palettes, style, min/max, squash). REQUIRED — at least one layer - must be supplied. Always pre-processed before URL emission: - (1) a default base layer (MODIS Terra True Color) is prepended - if none of the supplied layers' ids are in BASE_LAYERS — this - is load-bearing because Worldview shows a black background - otherwise; (2) the default reference overlays Coastlines_15m - and Reference_Features_15m are appended if not already present; - (3) the final list is canonically reordered (baselayers first, - overlays after) with user-supplied order preserved within each - partition. Pre-processing also applies to compare_layers when - compare is active. - projection: Map projection. Use 'geographic' for global Mercator (the - default), 'arctic' for north polar stereographic, or 'antarctic' - for south polar stereographic. - base_url: Worldview base URL. Override only for testing or alternate - deployments; default is the canonical production URL. - time: Map time. Accepts a date (daily resolution), a datetime - (subdaily, normalised to UTC), or a string in any reasonable - date/datetime format — ISO 8601, 'Sep 15, 2025', '2025/09/15', - TZ-aware forms, etc. (parsed via dateutil). If None, defaults - to yesterday (UTC) — Worldview's own "today" default can show - partially-rendered scenes because daily MODIS/VIIRS data is - still being ingested into GIBS; yesterday guarantees a - fully-rendered scene. Pass an explicit `date.today()` if the - partial today behaviour is what you want. Note: ambiguous - slash-separated strings like '01/02/2025' are interpreted as - month/day/year by default; pass an unambiguous form - ('2025-01-02') or a date object if the order matters. - bbox: Map viewport extent as (west, south, east, north). Degrees - for the geographic projection; projected meters for arctic/ - antarctic. If None, Worldview opens at its default global extent. - rotation: Map rotation in degrees, range -180 to 180. Honored only - by arctic/antarctic projections; ignored by geographic. - - compare_active: Activates Worldview's compare mode (side-by-side or - overlay of two layer states). Tri-state: - * None (default) — compare mode OFF; no compare params emitted - and any other compare_* args are silently ignored. - * True — compare mode ON with the A state shown as active. - * False — compare mode ON with the B state shown as active. - Maps to the URL `ca` param (None → omit; True → ca=true; False - → ca=false). - compare_layers: LayerSpec instances for the B state, same shape as - `layers`. REQUIRED when compare_active is not None; raises - ValueError if missing. Subject to the same layer pre-processing - described under `layers` (default base + reference overlays - auto-added; canonical reorder). - compare_time: Time for the B state, same accepted forms as `time`. - Optional even when compare is on; if omitted, the B state uses - the same time as the A state. - compare_mode: Comparison style. 'swipe' (vertical swiper between A - and B), 'spy' (lens-style hover view of B over A), or 'opacity' - (A overlaid on B with adjustable opacity). Only consulted when - compare is active. Default 'swipe' matches Worldview's default. - compare_value: Position of the swiper or value of the opacity - overlay, integer 0–100. Only consulted when compare is active. - Default 50. - - chart_active: Activates Worldview's charting mode (time-series of - regional statistics over a drawn area). False (default) → - charting OFF; no chart params emitted and any other chart_* args - are silently ignored. True → charting ON; emits cha=true. - chart_layer: GIBS layer ID to chart. Charting supports one layer - at a time. REQUIRED when chart_active=True; raises ValueError - if missing. - chart_area: Area-of-interest for the chart, as - (x1, y1, x2, y2) in the same coordinate system as `bbox`. - Statistics are computed over this region. - chart_time_start: Start of the chart's time range, same accepted - forms as `time`. Maps to the URL `cht` param. - chart_time_end: End of the chart's time range. Maps to the URL - `cht2` param. - chart_autoload: If True, the chart computes and renders the moment - the link is opened. Default False (user must click "Generate - Chart" in the Worldview UI). - - Returns: - A complete Worldview permalink URL string. - - Raises: - ValueError: If compare_active is not None but compare_layers is None. - ValueError: If chart_active is True but chart_layer is None. - - Examples: - Core layer + time + bbox:: - - url = build_worldview_permalink( - layers=[LayerSpec(id="MODIS_Terra_CorrectedReflectance_TrueColor")], - time="2025-09-15", - bbox=(-125, 32, -114, 42), - ) - - Custom opacity on one layer, compare mode A active, swipe at 60%:: - - url = build_worldview_permalink( - layers=[LayerSpec(id="MODIS_Terra_AOD", opacity=0.8)], - compare_active=True, - compare_layers=[LayerSpec(id="MODIS_Aqua_AOD")], - compare_time="2025-09-14", - compare_mode="swipe", - compare_value=60, - ) - - Charting (time series over a region):: - - url = build_worldview_permalink( - layers=[LayerSpec(id="VIIRS_SNPP_AOD")], - chart_active=True, - chart_layer="VIIRS_SNPP_AOD", - chart_area=(-125, 32, -114, 42), - chart_time_start="2025-09-01", - chart_time_end="2025-09-30", - chart_autoload=True, - ) - """ - params: dict[str, str] = {} - - layers = _apply_layer_preprocessing(layers) - params["l"] = ",".join(_format_layer(s) for s in layers) - - if compare_active is not None: - if compare_layers is None: - raise ValueError( - "compare_active is set, so compare_layers is required " - "(cannot enable compare mode without a B-state layer list)" - ) - compare_layers = _apply_layer_preprocessing(compare_layers) - params["l1"] = ",".join(_format_layer(s) for s in compare_layers) - - if time is None: - time = (datetime.now(timezone.utc) - timedelta(days=1)).date() - if (formatted := _format_time(time)) is not None: - params["t"] = formatted - - if compare_active is not None: - if (formatted := _format_time(compare_time)) is not None: - params["t1"] = formatted - - if bbox is not None: - params["v"] = ",".join(_fmt_num(x) for x in bbox) - - params["p"] = projection - - if rotation is not None: - params["r"] = _fmt_num(rotation) - - if compare_active is not None: - params["ca"] = "true" if compare_active else "false" - params["cm"] = compare_mode - params["cv"] = str(compare_value) - - if chart_active: - if chart_layer is None: - raise ValueError( - "chart_active is True, so chart_layer is required (cannot enable charting without a layer to chart)" - ) - params["cha"] = "true" - params["chl"] = chart_layer - if chart_area is not None: - params["chc"] = ",".join(_fmt_num(x) for x in chart_area) - if (formatted := _format_time(chart_time_start)) is not None: - params["cht"] = formatted - if (formatted := _format_time(chart_time_end)) is not None: - params["cht2"] = formatted - params["chch"] = "true" if chart_autoload else "false" - - params["em"] = "true" - - return f"{base_url}?{urlencode(params, safe=',()=:')}" - - -if __name__ == "__main__": - core = build_worldview_permalink( - layers=[LayerSpec(id="MODIS_Terra_CorrectedReflectance_TrueColor")], - time="2025-09-15", - bbox=(-125, 32, -114, 42), - ) - print("Core:", core) - - rich = build_worldview_permalink( - layers=[LayerSpec(id="MODIS_Terra_AOD", opacity=0.8)], - time="September 15, 2025", - bbox=(-125, 32, -114, 42), - compare_active=True, - compare_layers=[LayerSpec(id="MODIS_Aqua_AOD")], - compare_time="2025-09-14", - compare_mode="swipe", - compare_value=60, - chart_active=True, - chart_layer="MODIS_Terra_AOD", - chart_area=(-125, 32, -114, 42), - chart_time_start="2025-09-01", - chart_time_end="2025-09-30", - chart_autoload=True, - ) - print("Rich:", rich) diff --git a/tests/tools/worldview/test_utils.py b/tests/tools/worldview/test_permalink_assembly.py similarity index 76% rename from tests/tools/worldview/test_utils.py rename to tests/tools/worldview/test_permalink_assembly.py index dc457ff..4fa8a5e 100644 --- a/tests/tools/worldview/test_utils.py +++ b/tests/tools/worldview/test_permalink_assembly.py @@ -1,16 +1,28 @@ -"""Unit tests for worldview utils module.""" +"""Unit tests for `WorldviewPermalinkTool.build_url` URL-string assembly. + +Schema-level validation (gate constraints, range constraints) is covered in +`test_permalink.py`. Tests here exercise the pure URL-assembly path: pass a +validated `WorldviewPermalinkInputSchema` into `build_url` and assert on the +resulting URL. +""" from datetime import date, datetime, timedelta, timezone import pytest from pydantic import ValidationError -from akd_ext.tools.worldview.utils import ( +from akd_ext.tools.worldview.permalink import ( LayerSpec, - build_worldview_permalink, + WorldviewPermalinkInputSchema, + WorldviewPermalinkTool, ) +def _build(**kwargs) -> str: + """Construct schema + build URL — the standard test-side call pattern.""" + return WorldviewPermalinkTool.build_url(WorldviewPermalinkInputSchema(**kwargs)) + + def query_string(url: str) -> str: """Return the part of the URL after `?`.""" return url.split("?", 1)[1] @@ -26,38 +38,38 @@ def test_layerspec_id_is_required(self): def test_all_defaults_renders_bare_id(self): # LAYER_X is a non-base id, so pre-processing prepends a base and appends # default reference overlays. LAYER_X itself still renders bare (no parens). - url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X")]) + url = _build(layers=[LayerSpec(id="LAYER_X")]) assert ",LAYER_X," in query_string(url) # No layer in the resulting list has modifiers, so no parens anywhere. assert "(" not in query_string(url) def test_hidden(self): - url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", hidden=True)]) + url = _build(layers=[LayerSpec(id="LAYER_X", hidden=True)]) assert "LAYER_X(hidden)" in url def test_opacity(self): - url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", opacity=0.7)]) + url = _build(layers=[LayerSpec(id="LAYER_X", opacity=0.7)]) assert "LAYER_X(opacity=0.7)" in url def test_palettes(self): - url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", palettes=["red", "blue"])]) + url = _build(layers=[LayerSpec(id="LAYER_X", palettes=["red", "blue"])]) assert "LAYER_X(palettes=red,blue)" in url def test_style(self): - url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", style="vector_style")]) + url = _build(layers=[LayerSpec(id="LAYER_X", style="vector_style")]) assert "LAYER_X(style=vector_style)" in url def test_min_max(self): - url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", min=0, max=100)]) + url = _build(layers=[LayerSpec(id="LAYER_X", min=0, max=100)]) assert "LAYER_X(min=0,max=100)" in url def test_squash(self): - url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_X", squash=True)]) + url = _build(layers=[LayerSpec(id="LAYER_X", squash=True)]) assert "LAYER_X(squash)" in url def test_multiple_modifiers_use_documented_token_order(self): # _format_layer order: hidden, opacity, palettes, style, min, max, squash - url = build_worldview_permalink( + url = _build( layers=[ LayerSpec( id="LAYER_X", @@ -73,7 +85,7 @@ def test_multiple_modifiers_use_documented_token_order(self): def test_multiple_layers_comma_joined(self): # Both LAYER_A and LAYER_B are non-base; canonical reorder keeps user-supplied # order within the overlay partition. - url = build_worldview_permalink(layers=[LayerSpec(id="LAYER_A"), LayerSpec(id="LAYER_B", opacity=0.5)]) + url = _build(layers=[LayerSpec(id="LAYER_A"), LayerSpec(id="LAYER_B", opacity=0.5)]) assert "LAYER_A,LAYER_B(opacity=0.5)" in url @@ -81,19 +93,19 @@ class TestTimeFormatting: """Time conversion behaviour via the `time` param.""" def test_date_emits_daily_form(self): - url = build_worldview_permalink(layers=[LayerSpec(id="L")], time=date(2025, 9, 15)) + url = _build(layers=[LayerSpec(id="L")], time=date(2025, 9, 15)) assert "t=2025-09-15" in url assert "T" not in url.split("t=")[1].split("&")[0] def test_datetime_with_time_emits_subdaily(self): - url = build_worldview_permalink( + url = _build( layers=[LayerSpec(id="L")], time=datetime(2025, 9, 15, 12, 30, 45), ) assert "t=2025-09-15T12:30:45Z" in url def test_datetime_at_midnight_emits_daily(self): - url = build_worldview_permalink( + url = _build( layers=[LayerSpec(id="L")], time=datetime(2025, 9, 15, 0, 0, 0), ) @@ -103,33 +115,33 @@ def test_datetime_at_midnight_emits_daily(self): def test_tz_aware_datetime_normalised_to_utc(self): est = timezone(timedelta(hours=-5)) dt = datetime(2025, 9, 15, 12, 0, 0, tzinfo=est) - url = build_worldview_permalink(layers=[LayerSpec(id="L")], time=dt) + url = _build(layers=[LayerSpec(id="L")], time=dt) assert "t=2025-09-15T17:00:00Z" in url def test_string_iso_date(self): - url = build_worldview_permalink(layers=[LayerSpec(id="L")], time="2025-09-15") + url = _build(layers=[LayerSpec(id="L")], time="2025-09-15") assert "t=2025-09-15" in url def test_string_human_readable(self): - url = build_worldview_permalink(layers=[LayerSpec(id="L")], time="September 15, 2025") + url = _build(layers=[LayerSpec(id="L")], time="September 15, 2025") assert "t=2025-09-15" in url def test_string_slash_form(self): - url = build_worldview_permalink(layers=[LayerSpec(id="L")], time="2025/09/15") + url = _build(layers=[LayerSpec(id="L")], time="2025/09/15") assert "t=2025-09-15" in url def test_string_tz_aware_iso_normalises_to_utc(self): - url = build_worldview_permalink(layers=[LayerSpec(id="L")], time="2025-09-15T12:00:00-05:00") + url = _build(layers=[LayerSpec(id="L")], time="2025-09-15T12:00:00-05:00") assert "t=2025-09-15T17:00:00Z" in url def test_unparseable_string_raises(self): with pytest.raises(ValueError, match="banana"): - build_worldview_permalink(layers=[LayerSpec(id="L")], time="banana") + _build(layers=[LayerSpec(id="L")], time="banana") def test_none_defaults_to_yesterday_utc(self): - # Function defaults `time` to yesterday (UTC) to avoid Worldview's + # build_url defaults `time` to yesterday (UTC) to avoid Worldview's # partially-rendered "today" scenes. - url = build_worldview_permalink(layers=[LayerSpec(id="L")]) + url = _build(layers=[LayerSpec(id="L")]) yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).date().isoformat() assert f"t={yesterday}" in url @@ -138,18 +150,18 @@ class TestCoreParams: """bbox, projection, rotation.""" def test_bbox_round_trip(self): - url = build_worldview_permalink( + url = _build( layers=[LayerSpec(id="L")], bbox=(-125, 32, -114, 42), ) assert "v=-125,32,-114,42" in url def test_projection_default_is_geographic(self): - url = build_worldview_permalink(layers=[LayerSpec(id="L")]) + url = _build(layers=[LayerSpec(id="L")]) assert "p=geographic" in url def test_projection_arctic_with_rotation(self): - url = build_worldview_permalink( + url = _build( layers=[LayerSpec(id="L")], projection="arctic", rotation=45, @@ -158,13 +170,13 @@ def test_projection_arctic_with_rotation(self): assert "r=45" in url def test_no_rotation_omits_param(self): - url = build_worldview_permalink(layers=[LayerSpec(id="L")]) + url = _build(layers=[LayerSpec(id="L")]) assert "r=" not in query_string(url) def test_embed_mode_always_emitted(self): # Embed mode is unconditional — em=true must appear on every URL so # the link renders cleanly in chat / iframe contexts. - url = build_worldview_permalink(layers=[LayerSpec(id="L")]) + url = _build(layers=[LayerSpec(id="L")]) assert "em=true" in url @@ -172,7 +184,7 @@ class TestCompareMode: """compare_active gate behaviour.""" def test_gate_on_a_side_emits_full_block(self): - url = build_worldview_permalink( + url = _build( layers=[LayerSpec(id="L_A")], time="2025-09-15", compare_active=True, @@ -189,7 +201,7 @@ def test_gate_on_a_side_emits_full_block(self): assert "cv=50" in url def test_gate_on_b_side_emits_ca_false(self): - url = build_worldview_permalink( + url = _build( layers=[LayerSpec(id="L_A")], compare_active=False, compare_layers=[LayerSpec(id="L_B")], @@ -198,7 +210,7 @@ def test_gate_on_b_side_emits_ca_false(self): assert ",L_B," in url def test_gate_off_short_circuits_stray_args(self): - url = build_worldview_permalink( + url = _build( layers=[LayerSpec(id="L_A")], compare_active=None, compare_layers=[LayerSpec(id="L_B")], @@ -213,16 +225,8 @@ def test_gate_off_short_circuits_stray_args(self): assert "cm=" not in qs assert "cv=" not in qs - def test_gate_on_without_compare_layers_raises(self): - with pytest.raises(ValueError, match="compare_layers is required"): - build_worldview_permalink( - layers=[LayerSpec(id="L_A")], - compare_active=True, - compare_layers=None, - ) - def test_compare_time_is_optional_when_gate_on(self): - url = build_worldview_permalink( + url = _build( layers=[LayerSpec(id="L_A")], compare_active=True, compare_layers=[LayerSpec(id="L_B")], @@ -236,7 +240,7 @@ class TestChartingMode: """chart_active gate behaviour.""" def test_gate_on_emits_full_block(self): - url = build_worldview_permalink( + url = _build( layers=[LayerSpec(id="L")], chart_active=True, chart_layer="L_CHART", @@ -253,7 +257,7 @@ def test_gate_on_emits_full_block(self): assert "chch=true" in url def test_gate_off_short_circuits_stray_args(self): - url = build_worldview_permalink( + url = _build( layers=[LayerSpec(id="L")], chart_active=False, chart_layer="L_CHART", @@ -269,16 +273,8 @@ def test_gate_off_short_circuits_stray_args(self): assert "cht2=" not in qs assert "chch=" not in qs - def test_gate_on_without_chart_layer_raises(self): - with pytest.raises(ValueError, match="chart_layer is required"): - build_worldview_permalink( - layers=[LayerSpec(id="L")], - chart_active=True, - chart_layer=None, - ) - def test_chart_autoload_default_false(self): - url = build_worldview_permalink( + url = _build( layers=[LayerSpec(id="L")], chart_active=True, chart_layer="L_CHART", @@ -306,7 +302,7 @@ def _layer_list(self, url: str, key: str = "l") -> list[str]: ) def test_known_base_layer_is_recognised(self, base_id): # When the user supplies any known base, no second base is auto-prepended. - url = build_worldview_permalink(layers=[LayerSpec(id=base_id)]) + url = _build(layers=[LayerSpec(id=base_id)]) ids = self._layer_list(url) bases_in_url = [ i @@ -324,20 +320,20 @@ def test_known_base_layer_is_recognised(self, base_id): def test_auto_add_base_when_missing(self): # MODIS_Aqua_AOD is an overlay — pre-processor must prepend the default base. - url = build_worldview_permalink(layers=[LayerSpec(id="MODIS_Aqua_AOD")]) + url = _build(layers=[LayerSpec(id="MODIS_Aqua_AOD")]) ids = self._layer_list(url) assert ids[0] == "MODIS_Terra_CorrectedReflectance_TrueColor" assert "MODIS_Aqua_AOD" in ids def test_auto_append_default_reference_overlays(self): - url = build_worldview_permalink(layers=[LayerSpec(id="MODIS_Aqua_AOD")]) + url = _build(layers=[LayerSpec(id="MODIS_Aqua_AOD")]) assert "Coastlines_15m" in url assert "Reference_Features_15m" in url def test_partial_reference_overlay_already_present_only_missing_appended(self): # User supplies Coastlines_15m themselves; pre-processor must not duplicate it, # but must still append the missing Reference_Features_15m. - url = build_worldview_permalink(layers=[LayerSpec(id="MODIS_Aqua_AOD"), LayerSpec(id="Coastlines_15m")]) + url = _build(layers=[LayerSpec(id="MODIS_Aqua_AOD"), LayerSpec(id="Coastlines_15m")]) ids = self._layer_list(url) assert ids.count("Coastlines_15m") == 1 assert "Reference_Features_15m" in ids @@ -345,7 +341,7 @@ def test_partial_reference_overlay_already_present_only_missing_appended(self): def test_canonical_reorder_baselayers_first(self): # User supplies overlay before base; pre-processor moves base to front and # preserves user-supplied order within the overlay partition. - url = build_worldview_permalink( + url = _build( layers=[ LayerSpec(id="MODIS_Aqua_AOD"), # overlay LayerSpec(id="VIIRS_NOAA21_CorrectedReflectance_TrueColor"), # base @@ -360,7 +356,7 @@ def test_canonical_reorder_baselayers_first(self): def test_compare_layers_also_get_pre_processing(self): # compare_layers (B-state) gets the same pre-processing as `layers`. - url = build_worldview_permalink( + url = _build( layers=[LayerSpec(id="L_A")], compare_active=True, compare_layers=[LayerSpec(id="L_B")], From 06fd6ca437419e0e28350fb4b70821b9899fc6c2 Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Tue, 5 May 2026 15:18:15 -0500 Subject: [PATCH 10/16] add validations to the tool input schema. basing off worldview implementation --- akd_ext/tools/worldview/permalink.py | 134 +++++++++++++++++-- tests/tools/worldview/test_permalink.py | 166 ++++++++++++++++++++++++ 2 files changed, 292 insertions(+), 8 deletions(-) diff --git a/akd_ext/tools/worldview/permalink.py b/akd_ext/tools/worldview/permalink.py index 5bf6377..8077e00 100644 --- a/akd_ext/tools/worldview/permalink.py +++ b/akd_ext/tools/worldview/permalink.py @@ -12,10 +12,14 @@ from akd._base import InputSchema, OutputSchema from akd.tools import BaseTool, BaseToolConfig from dateutil import parser as date_parser -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator from akd_ext.mcp import mcp_tool +# ----------------------------------------------------------------------------- +# Module global variables +# ----------------------------------------------------------------------------- + DEFAULT_BASE_URL = "https://worldview.earthdata.nasa.gov/" BASE_LAYERS: tuple[str, ...] = ( @@ -29,6 +33,47 @@ DEFAULT_BASE_LAYER: str = BASE_LAYERS[0] DEFAULT_REFERENCE_OVERLAYS: tuple[str, ...] = ("Coastlines_15m", "Reference_Features_15m") +_FORBIDDEN_LAYER_CHARS: frozenset[str] = frozenset(",()") + + +# ----------------------------------------------------------------------------- +# Input/Output Schema with Validators +# ----------------------------------------------------------------------------- + + +def _reject_grammar_chars(value: str, field_name: str) -> str: + """Reject ',', '(', ')' — reserved by Worldview's layer-list grammar. + + The URL format `l=LayerID(mod,mod),LayerID2` uses these characters as + structural delimiters; embedding them inside an ID, style, or palette + silently corrupts the URL when Worldview parses it back. + """ + bad = _FORBIDDEN_LAYER_CHARS.intersection(value) + if bad: + raise ValueError( + f"{field_name}={value!r} contains forbidden character(s) {sorted(bad)}; " + f"',', '(', and ')' are reserved by Worldview's layer-list grammar" + ) + return value + + +def _coerce_to_datetime(t: str | date | datetime) -> datetime: + """Best-effort coerce a time value to a TZ-aware UTC datetime for comparison. + + Used in model validator during time comparision to check semantics. + """ + if isinstance(t, str): + try: + t = date_parser.parse(t) + except (ValueError, OverflowError) as e: + raise ValueError(f"could not parse time {t!r}: {e}") from e + # datetime is a subclass of date — check it first + if isinstance(t, datetime): + return t if t.tzinfo else t.replace(tzinfo=timezone.utc) + if isinstance(t, date): + return datetime.combine(t, datetime.min.time(), tzinfo=timezone.utc) + raise TypeError(f"unsupported time type: {type(t).__name__}") + class LayerSpec(BaseModel): """A single Worldview layer, optionally with rendering modifiers. @@ -53,6 +98,8 @@ class LayerSpec(BaseModel): ) opacity: float | None = Field( default=None, + ge=0.0, + le=1.0, description="Layer opacity, 0.0 (fully transparent) to 1.0 (fully opaque). None for full opacity.", ) palettes: list[str] | None = Field( @@ -81,14 +128,20 @@ class LayerSpec(BaseModel): ), ) + @field_validator("id", "style") + @classmethod + def _check_string_field(cls, v: str | None, info: ValidationInfo) -> str | None: + if v is not None: + _reject_grammar_chars(v, info.field_name) + return v -class WorldviewPermalinkToolConfig(BaseToolConfig): - """Configuration for the WorldviewPermalinkTool Tool.""" - - base_url: str = Field( - default=os.getenv("WORLDVIEW_BASE_URL", DEFAULT_BASE_URL), - description="Base URL for the NASA WORLDVIEW", - ) + @field_validator("palettes") + @classmethod + def _check_palettes(cls, v: list[str] | None) -> list[str] | None: + if v is not None: + for item in v: + _reject_grammar_chars(item, "palettes") + return v class WorldviewPermalinkInputSchema(InputSchema): @@ -134,6 +187,8 @@ class WorldviewPermalinkInputSchema(InputSchema): ) rotation: float | None = Field( default=None, + ge=-180.0, + le=180.0, description=( "Map rotation in degrees, range -180 to 180. Honored only by arctic/" "antarctic projections; ignored by geographic." @@ -217,6 +272,13 @@ class WorldviewPermalinkInputSchema(InputSchema): ), ) + @field_validator("chart_layer") + @classmethod + def _check_chart_layer(cls, v: str | None) -> str | None: + if v is not None: + _reject_grammar_chars(v, "chart_layer") + return v + @model_validator(mode="after") def _enforce_feature_gates(self) -> Self: if self.compare_active is not None and self.compare_layers is None: @@ -230,6 +292,43 @@ def _enforce_feature_gates(self) -> Self: ) return self + @model_validator(mode="after") + def _validate_semantics(self) -> Self: + if self.bbox is not None: + west, south, east, north = self.bbox + if south >= north: + raise ValueError(f"bbox south ({south}) must be < north ({north})") + if west == east: + raise ValueError(f"bbox west ({west}) must differ from east ({east}); zero-width bbox is invalid") + # west > east is allowed (antimeridian crossing in geographic projection) + if self.projection == "geographic": + if not (-180 <= west <= 180 and -180 <= east <= 180): + raise ValueError(f"bbox lon out of [-180, 180] for geographic projection: {self.bbox}") + if not (-90 <= south <= 90 and -90 <= north <= 90): + raise ValueError(f"bbox lat out of [-90, 90] for geographic projection: {self.bbox}") + + if self.chart_active and self.chart_area is not None: + x1, y1, x2, y2 = self.chart_area + if y1 >= y2: + raise ValueError(f"chart_area y1 ({y1}) must be < y2 ({y2})") + if x1 == x2: + raise ValueError(f"chart_area x1 ({x1}) must differ from x2 ({x2}); zero-width area is invalid") + if self.projection == "geographic": + if not (-180 <= x1 <= 180 and -180 <= x2 <= 180): + raise ValueError(f"chart_area lon out of [-180, 180] for geographic projection: {self.chart_area}") + if not (-90 <= y1 <= 90 and -90 <= y2 <= 90): + raise ValueError(f"chart_area lat out of [-90, 90] for geographic projection: {self.chart_area}") + + if self.chart_time_start is not None and self.chart_time_end is not None: + start = _coerce_to_datetime(self.chart_time_start) + end = _coerce_to_datetime(self.chart_time_end) + if start > end: + raise ValueError( + f"chart_time_start ({self.chart_time_start}) must be <= chart_time_end ({self.chart_time_end})" + ) + + return self + class WorldviewPermalinkOutputSchema(OutputSchema): """Output schema for the Worldview Permalink Tool.""" @@ -240,6 +339,25 @@ class WorldviewPermalinkOutputSchema(OutputSchema): ) +# ----------------------------------------------------------------------------- +# Tool Configuration +# ----------------------------------------------------------------------------- + + +class WorldviewPermalinkToolConfig(BaseToolConfig): + """Configuration for the WorldviewPermalinkTool Tool.""" + + base_url: str = Field( + default=os.getenv("WORLDVIEW_BASE_URL", DEFAULT_BASE_URL), + description="Base URL for the NASA WORLDVIEW", + ) + + +# ----------------------------------------------------------------------------- +# Permalink generation (mcp) Tool +# ----------------------------------------------------------------------------- + + @mcp_tool class WorldviewPermalinkTool(BaseTool[WorldviewPermalinkInputSchema, WorldviewPermalinkOutputSchema]): """ diff --git a/tests/tools/worldview/test_permalink.py b/tests/tools/worldview/test_permalink.py index 6fb70ec..65c138b 100644 --- a/tests/tools/worldview/test_permalink.py +++ b/tests/tools/worldview/test_permalink.py @@ -103,3 +103,169 @@ def test_minimal_input_validates(self): assert schema.compare_active is None assert schema.chart_active is False assert schema.projection == "geographic" + + +class TestGrammarCharRejection: + """Reject ',', '(', ')' anywhere they would corrupt the layer-list grammar.""" + + @pytest.mark.parametrize("bad_id", ["MODIS,Aqua", "A(B)", "A(", "A)", "X,Y,Z"]) + def test_layer_id_with_grammar_char_rejected(self, bad_id): + with pytest.raises(ValidationError, match="forbidden character"): + LayerSpec(id=bad_id) + + @pytest.mark.parametrize("bad_style", ["dashed,thick", "fancy(thing)", "a)b"]) + def test_layer_style_with_grammar_char_rejected(self, bad_style): + with pytest.raises(ValidationError, match="forbidden character"): + LayerSpec(id="L", style=bad_style) + + @pytest.mark.parametrize("bad_palette", ["red,blue", "p(1)", "x)"]) + def test_palette_item_with_grammar_char_rejected(self, bad_palette): + with pytest.raises(ValidationError, match="forbidden character"): + LayerSpec(id="L", palettes=[bad_palette]) + + def test_chart_layer_with_grammar_char_rejected(self): + with pytest.raises(ValidationError, match="forbidden character"): + WorldviewPermalinkInputSchema( + layers=[LayerSpec(id="L")], + chart_active=True, + chart_layer="X(Y)", + ) + + def test_safe_strings_accepted(self): + # Realistic GIBS-shaped IDs must not trip the validator. + spec = LayerSpec( + id="MODIS_Terra_CorrectedReflectance_TrueColor", + style="dashed", + palettes=["red_blue", "viridis"], + ) + assert spec.id == "MODIS_Terra_CorrectedReflectance_TrueColor" + assert spec.style == "dashed" + assert spec.palettes == ["red_blue", "viridis"] + + +class TestFieldBounds: + """Field-level numeric bounds added alongside the validators.""" + + @pytest.mark.parametrize("bad_opacity", [-0.1, 1.1, 2.0]) + def test_opacity_out_of_range_rejected(self, bad_opacity): + with pytest.raises(ValidationError): + LayerSpec(id="L", opacity=bad_opacity) + + @pytest.mark.parametrize("good_opacity", [0.0, 0.5, 1.0]) + def test_opacity_in_range_accepted(self, good_opacity): + spec = LayerSpec(id="L", opacity=good_opacity) + assert spec.opacity == good_opacity + + @pytest.mark.parametrize("bad_rotation", [-200, 200, 360]) + def test_rotation_out_of_range_rejected(self, bad_rotation): + with pytest.raises(ValidationError): + WorldviewPermalinkInputSchema(layers=[LayerSpec(id="L")], rotation=bad_rotation) + + @pytest.mark.parametrize("good_rotation", [-180, 0, 180]) + def test_rotation_in_range_accepted(self, good_rotation): + schema = WorldviewPermalinkInputSchema(layers=[LayerSpec(id="L")], rotation=good_rotation) + assert schema.rotation == good_rotation + + +class TestBboxValidation: + """bbox ordering, geographic-projection bounds, antimeridian carve-out, polar-projection skip.""" + + def test_zero_width_bbox_rejected(self): + with pytest.raises(ValidationError, match="zero-width bbox"): + WorldviewPermalinkInputSchema(layers=[LayerSpec(id="L")], bbox=(10.0, 0.0, 10.0, 5.0)) + + def test_inverted_latitude_rejected(self): + with pytest.raises(ValidationError, match="south .* must be < north"): + WorldviewPermalinkInputSchema(layers=[LayerSpec(id="L")], bbox=(-10.0, 20.0, 10.0, 0.0)) + + def test_antimeridian_crossing_accepted(self): + # Pacific-spanning bbox: west (170) > east (-170) is valid in geographic. + schema = WorldviewPermalinkInputSchema(layers=[LayerSpec(id="L")], bbox=(170.0, -10.0, -170.0, 10.0)) + assert schema.bbox == (170.0, -10.0, -170.0, 10.0) + + def test_lon_out_of_range_rejected_for_geographic(self): + with pytest.raises(ValidationError, match="bbox lon out of"): + WorldviewPermalinkInputSchema(layers=[LayerSpec(id="L")], bbox=(-200.0, 0.0, 0.0, 10.0)) + + def test_lat_out_of_range_rejected_for_geographic(self): + with pytest.raises(ValidationError, match="bbox lat out of"): + WorldviewPermalinkInputSchema(layers=[LayerSpec(id="L")], bbox=(-10.0, -100.0, 10.0, 0.0)) + + @pytest.mark.parametrize("projection", ["arctic", "antarctic"]) + def test_polar_projection_skips_lonlat_bounds(self, projection): + # Same out-of-lonlat-range bbox is accepted for polar projections (those use projected meters). + schema = WorldviewPermalinkInputSchema( + layers=[LayerSpec(id="L")], + projection=projection, + bbox=(-4_000_000.0, -4_000_000.0, 4_000_000.0, 4_000_000.0), + ) + assert schema.projection == projection + + @pytest.mark.parametrize("projection", ["arctic", "antarctic"]) + def test_polar_projection_still_enforces_ordering(self, projection): + with pytest.raises(ValidationError, match="south .* must be < north"): + WorldviewPermalinkInputSchema( + layers=[LayerSpec(id="L")], + projection=projection, + bbox=(-1000.0, 1000.0, 1000.0, -1000.0), + ) + + +class TestChartAreaValidation: + """chart_area follows the same rules as bbox; only consulted when chart_active.""" + + def _base(self, **overrides): + params = dict( + layers=[LayerSpec(id="L")], + chart_active=True, + chart_layer="L", + ) + params.update(overrides) + return params + + def test_zero_width_chart_area_rejected(self): + with pytest.raises(ValidationError, match="zero-width area"): + WorldviewPermalinkInputSchema(**self._base(chart_area=(10.0, 0.0, 10.0, 5.0))) + + def test_inverted_y_rejected(self): + with pytest.raises(ValidationError, match="y1 .* must be < y2"): + WorldviewPermalinkInputSchema(**self._base(chart_area=(-10.0, 20.0, 10.0, 0.0))) + + def test_lon_out_of_range_rejected_for_geographic(self): + with pytest.raises(ValidationError, match="chart_area lon out of"): + WorldviewPermalinkInputSchema(**self._base(chart_area=(-200.0, 0.0, 0.0, 10.0))) + + def test_chart_area_ignored_when_chart_inactive(self): + # When chart_active is False, chart_area validation is skipped — only emission is gated. + schema = WorldviewPermalinkInputSchema( + layers=[LayerSpec(id="L")], + chart_active=False, + chart_area=(10.0, 0.0, 10.0, 5.0), # would be rejected if chart_active=True + ) + assert schema.chart_active is False + + +class TestChartTimeOrdering: + """chart_time_start <= chart_time_end when both are supplied.""" + + def _base(self, **overrides): + params = dict(layers=[LayerSpec(id="L")], chart_active=True, chart_layer="L") + params.update(overrides) + return params + + def test_inverted_chart_time_rejected(self): + with pytest.raises(ValidationError, match="chart_time_start .* must be <="): + WorldviewPermalinkInputSchema(**self._base(chart_time_start="2025-09-30", chart_time_end="2025-09-01")) + + def test_equal_chart_time_accepted(self): + schema = WorldviewPermalinkInputSchema(**self._base(chart_time_start="2025-09-15", chart_time_end="2025-09-15")) + assert schema.chart_time_start == "2025-09-15" + + def test_ordered_chart_time_accepted(self): + schema = WorldviewPermalinkInputSchema(**self._base(chart_time_start="2025-09-01", chart_time_end="2025-09-30")) + assert schema.chart_time_end == "2025-09-30" + + def test_one_sided_chart_time_skipped(self): + # Only one of (start, end) supplied — order check is skipped. + schema = WorldviewPermalinkInputSchema(**self._base(chart_time_start="2025-09-15")) + assert schema.chart_time_end is None From d19f8fa437fada87efe79e9ac7c7abdc17e5266e Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Wed, 6 May 2026 11:27:22 -0500 Subject: [PATCH 11/16] working layerid --- akd_ext/tools/worldview/permalink.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/akd_ext/tools/worldview/permalink.py b/akd_ext/tools/worldview/permalink.py index 8077e00..a6c4c5b 100644 --- a/akd_ext/tools/worldview/permalink.py +++ b/akd_ext/tools/worldview/permalink.py @@ -531,7 +531,7 @@ def _format_time(t: str | date | datetime | None) -> str | None: if __name__ == "__main__": core = WorldviewPermalinkTool.build_url( WorldviewPermalinkInputSchema( - layers=[LayerSpec(id="MODIS_Terra_CorrectedReflectance_TrueColor")], + layers=[LayerSpec(id="MODIS_Terra_CorrectedReflectance_TrueColor"), LayerSpec(id="MODIS_Aqua_Aerosol")], time="2025-09-15", bbox=(-125, 32, -114, 42), ) @@ -540,16 +540,16 @@ def _format_time(t: str | date | datetime | None) -> str | None: rich = WorldviewPermalinkTool.build_url( WorldviewPermalinkInputSchema( - layers=[LayerSpec(id="MODIS_Terra_AOD", opacity=0.8)], + layers=[LayerSpec(id="MODIS_Aqua_Aerosol", opacity=0.8)], time="September 15, 2025", bbox=(-125, 32, -114, 42), compare_active=True, - compare_layers=[LayerSpec(id="MODIS_Aqua_AOD")], + compare_layers=[LayerSpec(id="MODIS_Aqua_Aerosol")], compare_time="2025-09-14", compare_mode="swipe", compare_value=60, chart_active=True, - chart_layer="MODIS_Terra_AOD", + chart_layer="MODIS_Aqua_Aerosol", chart_area=(-125, 32, -114, 42), chart_time_start="2025-09-01", chart_time_end="2025-09-30", From 553f1e0cb865e06809f994f3409fc0edb20603e5 Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Mon, 11 May 2026 13:14:39 -0500 Subject: [PATCH 12/16] change bbox and chart_area from tuple to list to improve JSON serialization compatibility while preserving the fixed-length invariant via Pydantic validators Switch and fields in WorldviewPermalinkInputSchema from to with min_length/max_length=4 constraints. --- akd_ext/tools/worldview/permalink.py | 12 ++++++++---- tests/tools/worldview/test_permalink.py | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/akd_ext/tools/worldview/permalink.py b/akd_ext/tools/worldview/permalink.py index a6c4c5b..07006e9 100644 --- a/akd_ext/tools/worldview/permalink.py +++ b/akd_ext/tools/worldview/permalink.py @@ -177,10 +177,12 @@ class WorldviewPermalinkInputSchema(InputSchema): "('2025-01-02') if the order matters." ), ) - bbox: tuple[float, float, float, float] | None = Field( + bbox: list[float] | None = Field( default=None, + min_length=4, + max_length=4, description=( - "Map viewport extent as (west, south, east, north). Degrees for the " + "Map viewport extent as [west, south, east, north]. Degrees for the " "geographic projection; projected meters for arctic/antarctic. " "If None, Worldview opens at its default global extent." ), @@ -249,10 +251,12 @@ class WorldviewPermalinkInputSchema(InputSchema): default=None, description=("GIBS layer ID to chart. Charting supports one layer at a time. REQUIRED when chart_active=True."), ) - chart_area: tuple[float, float, float, float] | None = Field( + chart_area: list[float] | None = Field( default=None, + min_length=4, + max_length=4, description=( - "Area-of-interest for the chart, as (x1, y1, x2, y2) in the same coordinate " + "Area-of-interest for the chart, as [x1, y1, x2, y2] in the same coordinate " "system as `bbox`. Statistics are computed over this region." ), ) diff --git a/tests/tools/worldview/test_permalink.py b/tests/tools/worldview/test_permalink.py index 65c138b..abca0e4 100644 --- a/tests/tools/worldview/test_permalink.py +++ b/tests/tools/worldview/test_permalink.py @@ -181,7 +181,7 @@ def test_inverted_latitude_rejected(self): def test_antimeridian_crossing_accepted(self): # Pacific-spanning bbox: west (170) > east (-170) is valid in geographic. schema = WorldviewPermalinkInputSchema(layers=[LayerSpec(id="L")], bbox=(170.0, -10.0, -170.0, 10.0)) - assert schema.bbox == (170.0, -10.0, -170.0, 10.0) + assert schema.bbox == [170.0, -10.0, -170.0, 10.0] def test_lon_out_of_range_rejected_for_geographic(self): with pytest.raises(ValidationError, match="bbox lon out of"): From 2bbc3e76e1bbb4985993ce6561b8c4be9d6e352d Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Mon, 11 May 2026 13:18:01 -0500 Subject: [PATCH 13/16] expose permalink tool via akd_ext.tools module --- akd_ext/tools/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/akd_ext/tools/__init__.py b/akd_ext/tools/__init__.py index fb1328e..343fbe8 100644 --- a/akd_ext/tools/__init__.py +++ b/akd_ext/tools/__init__.py @@ -21,6 +21,13 @@ RepositorySearchToolConfig, ) +from .worldview import ( + LayerSpec, + WorldviewPermalinkInputSchema, + WorldviewPermalinkOutputSchema, + WorldviewPermalinkTool, +) + __all__ = [ "DummyTool", "DummyInputSchema", @@ -38,4 +45,8 @@ "RepositorySearchToolInputSchema", "RepositorySearchToolOutputSchema", "RepositorySearchToolConfig", + "LayerSpec", + "WorldviewPermalinkInputSchema", + "WorldviewPermalinkOutputSchema", + "WorldviewPermalinkTool", ] From fcc018d89fac0386a8fa1862f2d5a3e8fae96b78 Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Mon, 11 May 2026 16:22:14 -0500 Subject: [PATCH 14/16] pat in pyproject.toml to pull in private akd-core dependency. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6e6617e..37e4b1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ ] requires-python = ">=3.12" dependencies = [ - "akd @ git+https://github.com/NASA-IMPACT/akd-core.git@develop", + "akd @ git+https://${GITHUB_TOKEN}@github.com/NASA-IMPACT/akd-core.git@develop", "fastmcp>=2.0.0,<3.2.4", "griffe>=1.0.0,<2", "openai-agents>=0.6.7", From b6144483d6d519293d0ef43240575d5bb20d18f5 Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Thu, 14 May 2026 14:37:32 -0500 Subject: [PATCH 15/16] only expose permalink generation tool for the fast mcp cloud module resolution to be faster. this change is for mcp preview only, remove it later before merge --- akd_ext/tools/__init__.py | 72 +++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/akd_ext/tools/__init__.py b/akd_ext/tools/__init__.py index 343fbe8..8472043 100644 --- a/akd_ext/tools/__init__.py +++ b/akd_ext/tools/__init__.py @@ -1,25 +1,25 @@ """Tools module for akd_ext.""" -from .dummy import DummyInputSchema, DummyOutputSchema, DummyTool -from .sde_search import ( - SDEDocument, - SDESearchTool, - SDESearchToolConfig, - SDESearchToolInputSchema, - SDESearchToolOutputSchema, -) -from .code_search.code_signals import ( - CodeSignalsSearchInputSchema, - CodeSignalsSearchOutputSchema, - CodeSignalsSearchTool, - CodeSignalsSearchToolConfig, -) -from .code_search.repository_search import ( - RepositorySearchTool, - RepositorySearchToolInputSchema, - RepositorySearchToolOutputSchema, - RepositorySearchToolConfig, -) +# from .dummy import DummyInputSchema, DummyOutputSchema, DummyTool +# from .sde_search import ( +# SDEDocument, +# SDESearchTool, +# SDESearchToolConfig, +# SDESearchToolInputSchema, +# SDESearchToolOutputSchema, +# ) +# from .code_search.code_signals import ( +# CodeSignalsSearchInputSchema, +# CodeSignalsSearchOutputSchema, +# CodeSignalsSearchTool, +# CodeSignalsSearchToolConfig, +# ) +# from .code_search.repository_search import ( +# RepositorySearchTool, +# RepositorySearchToolInputSchema, +# RepositorySearchToolOutputSchema, +# RepositorySearchToolConfig, +# ) from .worldview import ( LayerSpec, @@ -29,22 +29,22 @@ ) __all__ = [ - "DummyTool", - "DummyInputSchema", - "DummyOutputSchema", - "SDESearchTool", - "SDESearchToolInputSchema", - "SDESearchToolOutputSchema", - "SDESearchToolConfig", - "SDEDocument", - "CodeSignalsSearchInputSchema", - "CodeSignalsSearchOutputSchema", - "CodeSignalsSearchTool", - "CodeSignalsSearchToolConfig", - "RepositorySearchTool", - "RepositorySearchToolInputSchema", - "RepositorySearchToolOutputSchema", - "RepositorySearchToolConfig", + # "DummyTool", + # "DummyInputSchema", + # "DummyOutputSchema", + # "SDESearchTool", + # "SDESearchToolInputSchema", + # "SDESearchToolOutputSchema", + # "SDESearchToolConfig", + # "SDEDocument", + # "CodeSignalsSearchInputSchema", + # "CodeSignalsSearchOutputSchema", + # "CodeSignalsSearchTool", + # "CodeSignalsSearchToolConfig", + # "RepositorySearchTool", + # "RepositorySearchToolInputSchema", + # "RepositorySearchToolOutputSchema", + # "RepositorySearchToolConfig", "LayerSpec", "WorldviewPermalinkInputSchema", "WorldviewPermalinkOutputSchema", From d7245d2a098980dc0fa021c8b0f2d8bb71ad54cc Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Thu, 14 May 2026 14:58:32 -0500 Subject: [PATCH 16/16] update tool description to fill in possible voids --- akd_ext/tools/worldview/permalink.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/akd_ext/tools/worldview/permalink.py b/akd_ext/tools/worldview/permalink.py index 07006e9..8e73090 100644 --- a/akd_ext/tools/worldview/permalink.py +++ b/akd_ext/tools/worldview/permalink.py @@ -378,7 +378,10 @@ class WorldviewPermalinkTool(BaseTool[WorldviewPermalinkInputSchema, WorldviewPe "Analysis Support" steps. Required: - - layers: at least one LayerSpec (GIBS layer ID + optional rendering modifiers) + - layers: at least one LayerSpec (GIBS layer ID + optional rendering modifiers). + Layer IDs must be GIBS layer IDs (e.g. `MODIS_Terra_CorrectedReflectance_TrueColor`, + `VIIRS_SNPP_AOD`), NOT CMR collection concept_ids (`C-`). The + latter will produce a valid-looking URL that renders blank. Optional viewport / time: - projection, time, bbox, rotation — omit any to inherit Worldview's defaults