Fix colormap parsing against live GIBS API

Two bugs found via headless Claude CLI integration testing:

1. Colormap ID extraction: _detect_colormap returned the layer
   identifier as colormap_id, but GIBS layers often share colormaps
   under different filenames (e.g., AMSRU2_Sea_Ice_Concentration_12km
   -> colormap AMSR2_Sea_Ice_Concentration.xml). Now parses the actual
   filename from the metadata xlink:href.

2. Entries wrapper: GIBS v1.3 colormap XML nests ColorMapEntry elements
   inside an <Entries> wrapper. The parser only searched direct children
   of <ColorMap>, finding zero entries from live data. Now checks
   Entries/ColorMapEntry first, falling back to direct children.

Updated test fixture to use the real <Entries> wrapper structure.
This commit is contained in:
Ryan Malloy 2026-02-18 15:47:52 -07:00
parent d163ade9a4
commit 31dff49351
3 changed files with 52 additions and 25 deletions

View File

@ -117,25 +117,44 @@ def _parse_bbox(layer_el: ET.Element) -> BBox | None:
return BBox(west=west, south=south, east=east, north=north) return BBox(west=west, south=south, east=east, north=north)
def _extract_colormap_id_from_href(href: str) -> str | None:
"""Extract the colormap identifier from a GIBS colormap URL.
GIBS metadata hrefs look like:
https://gibs.earthdata.nasa.gov/colormaps/v1.3/AMSR2_Sea_Ice_Concentration.xml
Multiple resolution layers often share a single colormap file, so the
colormap filename may differ from the layer identifier.
"""
if not href:
return None
filename = href.rstrip("/").rsplit("/", 1)[-1]
if filename.endswith(".xml"):
return filename[:-4]
return None
def _detect_colormap(layer_el: ET.Element, identifier: str) -> tuple[bool, str | None]: def _detect_colormap(layer_el: ET.Element, identifier: str) -> tuple[bool, str | None]:
"""Detect whether a layer has a colormap. """Detect whether a layer has a colormap.
GIBS layers with colormaps typically have: GIBS layers with colormaps typically have:
- Metadata with xlink:role containing "colormap" (includes the URL)
- A LegendURL element inside a Style - A LegendURL element inside a Style
- Metadata with xlink:role containing "colormap"
Returns (has_colormap, colormap_id). Returns (has_colormap, colormap_id). The colormap_id is extracted from
the metadata href when available, falling back to the layer identifier.
""" """
# Check for LegendURL in any Style # Check metadata first — the href carries the actual colormap filename
for style in _findall(layer_el, "Style", _WMTS_URI):
if _find(style, "LegendURL", _WMTS_URI) is not None:
return True, identifier
# Check metadata for colormap hints
for meta in _findall(layer_el, "Metadata", _OWS_URI): for meta in _findall(layer_el, "Metadata", _OWS_URI):
role = meta.get(f"{{{_XLINK_URI}}}role", "") role = meta.get(f"{{{_XLINK_URI}}}role", "")
href = meta.get(f"{{{_XLINK_URI}}}href", "") href = meta.get(f"{{{_XLINK_URI}}}href", "")
if "colormap" in role.lower() or "colormap" in href.lower(): if "colormap" in role.lower() or "colormap" in href.lower():
colormap_id = _extract_colormap_id_from_href(href) or identifier
return True, colormap_id
# Fallback: check for LegendURL in any Style
for style in _findall(layer_el, "Style", _WMTS_URI):
if _find(style, "LegendURL", _WMTS_URI) is not None:
return True, identifier return True, identifier
return False, None return False, None

View File

@ -209,9 +209,13 @@ def parse_colormap(xml_text: str) -> ColorMapSet:
title = cm_elem.get("title", "") title = cm_elem.get("title", "")
units = cm_elem.get("units", "") units = cm_elem.get("units", "")
# Parse entries # Parse entries — GIBS v1.3 wraps them in <Entries>, but some
# documents have <ColorMapEntry> as direct children of <ColorMap>.
entries: list[ColorMapEntry] = [] entries: list[ColorMapEntry] = []
for entry_elem in cm_elem.findall("ColorMapEntry"): entry_elements = cm_elem.findall("Entries/ColorMapEntry")
if not entry_elements:
entry_elements = cm_elem.findall("ColorMapEntry")
for entry_elem in entry_elements:
rgb_raw = entry_elem.get("rgb", "0,0,0") rgb_raw = entry_elem.get("rgb", "0,0,0")
entries.append( entries.append(
ColorMapEntry( ColorMapEntry(

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<ColorMaps xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <ColorMaps xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ColorMap title="Surface Air Temperature" units="K"> <ColorMap title="Surface Air Temperature" units="K">
<Entries>
<ColorMapEntry rgb="227,245,255" transparent="false" sourceValue="[0,1)" value="[-INF,200.0)" ref="1"/> <ColorMapEntry rgb="227,245,255" transparent="false" sourceValue="[0,1)" value="[-INF,200.0)" ref="1"/>
<ColorMapEntry rgb="204,234,252" transparent="false" sourceValue="[1,2)" value="[200.0,210.0)" ref="2"/> <ColorMapEntry rgb="204,234,252" transparent="false" sourceValue="[1,2)" value="[200.0,210.0)" ref="2"/>
<ColorMapEntry rgb="150,200,230" transparent="false" sourceValue="[2,3)" value="[210.0,220.0)" ref="3"/> <ColorMapEntry rgb="150,200,230" transparent="false" sourceValue="[2,3)" value="[210.0,220.0)" ref="3"/>
@ -15,6 +16,7 @@
<ColorMapEntry rgb="220,50,30" transparent="false" sourceValue="[11,12)" value="[300.0,310.0)" ref="12"/> <ColorMapEntry rgb="220,50,30" transparent="false" sourceValue="[11,12)" value="[300.0,310.0)" ref="12"/>
<ColorMapEntry rgb="180,20,50" transparent="false" sourceValue="[12,13)" value="[310.0,320.0)" ref="13"/> <ColorMapEntry rgb="180,20,50" transparent="false" sourceValue="[12,13)" value="[310.0,320.0)" ref="13"/>
<ColorMapEntry rgb="251,3,207" transparent="false" sourceValue="[13,14)" value="[320.0,+INF)" ref="14"/> <ColorMapEntry rgb="251,3,207" transparent="false" sourceValue="[13,14)" value="[320.0,+INF)" ref="14"/>
</Entries>
<Legend type="continuous"> <Legend type="continuous">
<LegendEntry rgb="227,245,255" tooltip="&lt; 200 K" id="1"/> <LegendEntry rgb="227,245,255" tooltip="&lt; 200 K" id="1"/>
<LegendEntry rgb="204,234,252" tooltip="200 - 210 K" id="2" showTick="true" showLabel="true"/> <LegendEntry rgb="204,234,252" tooltip="200 - 210 K" id="2" showTick="true" showLabel="true"/>
@ -24,7 +26,9 @@
</Legend> </Legend>
</ColorMap> </ColorMap>
<ColorMap> <ColorMap>
<Entries>
<ColorMapEntry rgb="0,0,0" transparent="true" sourceValue="-9999" nodata="true" label="Missing Data"/> <ColorMapEntry rgb="0,0,0" transparent="true" sourceValue="-9999" nodata="true" label="Missing Data"/>
</Entries>
<Legend type="classification"> <Legend type="classification">
<LegendEntry rgb="0,0,0" tooltip="Missing Data" id="1"/> <LegendEntry rgb="0,0,0" tooltip="Missing Data" id="1"/>
</Legend> </Legend>