earthroverprogram/lucas-mega
LUCAS-MEGA LUCAS-MEGA: A Large-Scale Multimodal Dataset for Representation Learning in Soil-Environment Systems Manuscript Introduction LUCAS-MEGA is a large-scale multimodal dataset for soil-environment systems, built by fusing heterogeneous European soil and environmental datasets with the LUCAS soil survey as the backbone. The released dataset contains: 72,000+ soil samples 1,000+ fused soil and environmental features 68 integrated ESDAC source datasets… See the full description on the dataset page: https://huggingface.co/datasets/earthroverprogram/lucas-mega.
0303
1import ast2import colorsys3import hashlib4import json5from pathlib import Path6 7import numpy as np8import pandas as pd9 10try:11 import pydeck as pdk12 import streamlit as st13except ImportError as exc:14 raise SystemExit(15 "viewer_fusion.py requires streamlit and pydeck.\n"16 "Install them with: pip install streamlit pydeck\n"17 "Then run: streamlit run viewer_fusion.py"18 ) from exc19 20BASE_DIR = Path(__file__).resolve().parent21FUSION_DIR = BASE_DIR / "datasets" / "fusion"22ICON_PATH = BASE_DIR / "resources" / "erp.jpeg"23TABLE_PATH = FUSION_DIR / "data_table.csv"24META_NAMES_PATH = FUSION_DIR / "meta_column_names.json"25META_COMPLETE_PATH = FUSION_DIR / "meta_column_complete.json"26DEFAULT_PROPERTY = "texture:USDA_class"27DEFAULT_VIEWPORT = {"lat": 50.0, "lon": 10.0, "zoom": 3.2}28MAP_HEIGHT_PX = 56029 30CORE_UI_PROPERTIES = [31 {"label": "USDA texture class", "property": "texture:USDA_class"},32 {"label": "clay percentage", "property": "texture:clay_percentage (%)"},33 {"label": "silt percentage", "property": "texture:silt_percentage (%)"},34 {"label": "sand percentage", "property": "texture:sand_percentage (%)"},35 {"label": "coarse fragments", "property": "texture:coarse_percentage (%)"},36 {"label": "bulk density", "property": "mass_density:bulk_density (g/cm³)"},37 {"label": "bulk density 0-10cm", "property": "mass_density:bulk_density_0_10cm (g/cm³)"},38 {"label": "bulk density 10-20cm", "property": "mass_density:bulk_density_10_20cm (g/cm³)"},39 {"label": "pH in water", "property": "chemical:pH_in_H2O"},40 {"label": "pH in CaCl2", "property": "chemical:pH_in_CaCl2"},41 {"label": "organic carbon", "property": "carbon:organic_carbon_content (g/kg)"},42 {"label": "topsoil organic carbon", "property": "carbon:organic_carbon_content_topsoil (g/kg)"},43 {"label": "calcium carbonate", "property": "carbon:CaCO3_content (g/kg)"},44 {"label": "extractable nitrogen", "property": "fertility:N_extractable (g/kg)"},45 {"label": "extractable phosphorus", "property": "fertility:P_extractable (mg/kg)"},46 {"label": "extractable potassium", "property": "fertility:K_extractable (mg/kg)"},47 {"label": "cation exchange capacity", "property": "fertility:cation_exchange_capacity (cmol(+)/kg)"},48 {"label": "annual precipitation", "property": "climate:annual_precipitation (mm)"},49 {"label": "annual temperature", "property": "climate:annual_temperature (°C)"},50 {"label": "elevation", "property": "topography_geology:elevation (m)"},51 {"label": "slope", "property": "topography_geology:slope (deg)"},52]53 54CORE_VIEWPORTS = {55 "europe": {"lat": 50.0, "lon": 10.0, "zoom": 3.2},56 "iberia": {"lat": 40.0, "lon": -4.0, "zoom": 5.0},57 "portugal": {"lat": 39.6, "lon": -8.0, "zoom": 6.0},58 "spain": {"lat": 40.3, "lon": -3.7, "zoom": 5.6},59 "france": {"lat": 46.6, "lon": 2.2, "zoom": 5.4},60 "germany": {"lat": 51.2, "lon": 10.4, "zoom": 5.5},61 "italy": {"lat": 42.8, "lon": 12.5, "zoom": 5.4},62 "uk": {"lat": 54.2, "lon": -2.5, "zoom": 5.3},63 "ireland": {"lat": 53.4, "lon": -8.0, "zoom": 6.0},64 "netherlands": {"lat": 52.2, "lon": 5.3, "zoom": 7.0},65 "poland": {"lat": 52.1, "lon": 19.4, "zoom": 5.7},66 "greece": {"lat": 39.0, "lon": 22.0, "zoom": 5.6},67 "scandinavia": {"lat": 62.0, "lon": 15.0, "zoom": 4.2},68 "balkans": {"lat": 44.0, "lon": 20.0, "zoom": 5.0},69}70 71BASE_COLUMNS = [72 "id",73 "LAT_LONG",74 "GADM_IDS",75 "GADM_NAMES",76 "COUNTRY_CODE",77 "SAMPLE_DATE",78 "SAMPLE_DEPTH_RANGE_CM",79 "SAMPLE_SOURCE_DATASET",80]81 82 83def split_property_name(name):84 if ":" not in name:85 return "other", name86 theme, prop = name.split(":", 1)87 return theme, prop88 89 90def init_ui_state():91 st.session_state.setdefault("selected_property", DEFAULT_PROPERTY)92 st.session_state.setdefault("viewport", DEFAULT_VIEWPORT.copy())93 st.session_state.setdefault("ui_agent_messages", [])94 95 96def apply_compact_layout():97 st.markdown(98 """99 <style>100 .block-container {101 max-width: 100%;102 padding-top: 1.0rem;103 padding-right: 1.25rem;104 padding-left: 1.25rem;105 padding-bottom: 1.25rem;106 }107 [data-testid="stSidebar"] .block-container {108 padding-top: 1.0rem;109 }110 h1 {111 margin-top: 0;112 margin-bottom: 0.35rem;113 }114 div[data-testid="stCaptionContainer"] {115 margin-bottom: 0.4rem;116 }117 </style>118 """,119 unsafe_allow_html=True,120 )121 122 123@st.cache_data(show_spinner=False)124def list_openai_models(api_key):125 try:126 from openai import OpenAI127 except ImportError:128 return [], "OpenAI SDK is not installed. Install it with: pip install openai"129 130 try:131 client = OpenAI(api_key=api_key)132 models = client.models.list()133 except Exception as exc:134 return [], f"Could not load OpenAI models: {exc}"135 136 model_ids = sorted(model.id for model in models.data)137 chat_like = [138 model_id139 for model_id in model_ids140 if model_id.startswith(("gpt-", "o"))141 and not any(token in model_id for token in ("audio", "transcribe", "tts", "image", "realtime"))142 ]143 return chat_like or model_ids, None144 145 146@st.cache_data(show_spinner=False)147def load_metadata():148 with open(META_NAMES_PATH, encoding="utf-8") as f:149 names = json.load(f)["column_names"]150 151 with open(META_COMPLETE_PATH, encoding="utf-8") as f:152 meta = json.load(f)153 154 groups = {}155 for name in names:156 theme, prop = split_property_name(name)157 groups.setdefault(theme, []).append((prop, name))158 159 for theme in groups:160 groups[theme].sort(key=lambda item: item[0].lower())161 162 return names, meta, dict(sorted(groups.items()))163 164 165def parse_lat_long(value):166 if pd.isna(value):167 return np.nan, np.nan168 if isinstance(value, str):169 try:170 parsed = ast.literal_eval(value)171 except (SyntaxError, ValueError):172 return np.nan, np.nan173 else:174 parsed = value175 if not isinstance(parsed, (list, tuple)) or len(parsed) < 2:176 return np.nan, np.nan177 return float(parsed[0]), float(parsed[1])178 179 180def vector_mean(value):181 if pd.isna(value) or value == "":182 return np.nan183 if isinstance(value, str):184 try:185 value = ast.literal_eval(value)186 except (SyntaxError, ValueError):187 return np.nan188 if not isinstance(value, (list, tuple)):189 return np.nan190 nums = pd.to_numeric(pd.Series(value), errors="coerce").dropna()191 return float(nums.mean()) if len(nums) else np.nan192 193 194@st.cache_data(show_spinner=False)195def load_property_frame(property_name):196 columns = [197 "id",198 "LAT_LONG",199 "GADM_NAMES",200 "COUNTRY_CODE",201 "SAMPLE_DEPTH_RANGE_CM",202 "SAMPLE_SOURCE_DATASET",203 property_name,204 ]205 df = pd.read_csv(206 TABLE_PATH,207 usecols=columns,208 low_memory=False,209 keep_default_na=True,210 )211 212 lat_lon = df["LAT_LONG"].map(parse_lat_long)213 df["lat"] = [item[0] for item in lat_lon]214 df["lon"] = [item[1] for item in lat_lon]215 df = df.dropna(subset=["lat", "lon"])216 return df217 218 219def parse_sample_identity(sample_id):220 parts = str(sample_id).rsplit("_", 2)221 if len(parts) == 3:222 dataset_id, point_id, sample_id = parts223 return dataset_id, point_id, sample_id224 return "", str(sample_id), str(sample_id)225 226 227COLOR_STOPS = [228 (68, 1, 84),229 (59, 82, 139),230 (33, 145, 140),231 (94, 201, 98),232 (253, 231, 37),233]234 235 236def interpolate_color(value, vmin, vmax):237 if pd.isna(value):238 return [150, 150, 150, 55]239 if pd.isna(vmin) or pd.isna(vmax) or vmax <= vmin:240 t = 0.5241 else:242 t = float((value - vmin) / (vmax - vmin))243 t = max(0.0, min(1.0, t))244 245 pos = t * (len(COLOR_STOPS) - 1)246 left = int(np.floor(pos))247 right = min(left + 1, len(COLOR_STOPS) - 1)248 frac = pos - left249 rgb = [250 int(COLOR_STOPS[left][i] + frac * (COLOR_STOPS[right][i] - COLOR_STOPS[left][i]))251 for i in range(3)252 ]253 return rgb + [180]254 255 256def category_color(value):257 if pd.isna(value) or value == "":258 return [150, 150, 150, 55]259 digest = hashlib.md5(str(value).encode("utf-8")).hexdigest()260 hue = int(digest[:8], 16) / 0xFFFFFFFF261 red, green, blue = colorsys.hsv_to_rgb(hue, 0.62, 0.92)262 return [int(red * 255), int(green * 255), int(blue * 255), 185]263 264 265def get_visual_mode(property_meta):266 datatype = property_meta.get("datatype")267 is_array = property_meta.get("is_array_valued", False)268 if is_array:269 return "numeric vector mean"270 if datatype in {"int", "float"}:271 return "numeric scalar"272 return "categorical"273 274 275def calculate_color_values(df, property_name, property_meta):276 raw = df[property_name]277 mode = get_visual_mode(property_meta)278 279 if mode == "numeric vector mean":280 values = raw.map(vector_mean)281 elif mode == "numeric scalar":282 values = pd.to_numeric(raw, errors="coerce")283 else:284 values = raw.fillna("").astype(str)285 return raw, values, mode286 287 288def prepare_visual_values(df, property_name, property_meta, color_limits=None):289 raw, values, mode = calculate_color_values(df, property_name, property_meta)290 291 out = df.copy()292 out["display_value"] = raw.fillna("").astype(str)293 294 if mode.startswith("numeric"):295 non_null = values.dropna()296 if len(non_null):297 default_vmin = float(non_null.quantile(0.02))298 default_vmax = float(non_null.quantile(0.98))299 else:300 default_vmin = default_vmax = np.nan301 if color_limits:302 vmin, vmax = color_limits303 else:304 vmin, vmax = default_vmin, default_vmax305 out["color_value"] = values306 out["color"] = [interpolate_color(v, vmin, vmax) for v in values]307 legend = {308 "mode": mode,309 "valid": int(values.notna().sum()),310 "missing": int(values.isna().sum()),311 "min": float(non_null.min()) if len(non_null) else None,312 "max": float(non_null.max()) if len(non_null) else None,313 "p02": default_vmin if len(non_null) else None,314 "p98": default_vmax if len(non_null) else None,315 "vmin": vmin if len(non_null) else None,316 "vmax": vmax if len(non_null) else None,317 }318 else:319 categories = values.replace("", np.nan)320 unique_count = int(categories.nunique(dropna=True))321 out["color_value"] = values322 out["color"] = [category_color(v) for v in values]323 legend = {324 "mode": mode,325 "valid": int(categories.notna().sum()),326 "missing": int(categories.isna().sum()),327 "unique": unique_count,328 "top_values": categories.value_counts(dropna=True).head(12).to_dict(),329 }330 331 out["property"] = property_name332 return out, legend333 334 335def render_sidebar(groups, meta):336 st.sidebar.title("Fusion Viewer")337 338 api_key = st.sidebar.text_input(339 "OpenAI API token",340 type="password",341 help="Used only for this browser session. It is not saved to disk.",342 )343 model = None344 agent_enabled = False345 if api_key.strip():346 with st.sidebar.spinner("Loading models..."):347 models, model_error = list_openai_models(api_key.strip())348 if model_error:349 st.sidebar.warning(model_error)350 elif models:351 preferred = "gpt-5"352 default_index = models.index(preferred) if preferred in models else 0353 model = st.sidebar.selectbox("UI agent model", models, index=default_index)354 agent_enabled = True355 else:356 st.sidebar.warning("No OpenAI models available for this API token.")357 else:358 st.sidebar.selectbox(359 "UI agent model",360 ["Enter API token first"],361 index=0,362 disabled=True,363 )364 365 search = st.sidebar.text_input(366 "Search property",367 "",368 placeholder="type part of theme:name (unit)",369 )370 if search.strip():371 needle = search.strip().lower()372 matches = [373 name374 for theme_items in groups.values()375 for _, name in theme_items376 if needle in name.lower()377 ]378 if not matches:379 st.sidebar.warning("No matching properties.")380 return None381 st.sidebar.caption(f"{len(matches)} matching properties")382 property_name = st.sidebar.radio(383 "Matching properties",384 matches[:80],385 index=matches[:80].index(st.session_state.selected_property)386 if st.session_state.selected_property in matches[:80]387 else 0,388 format_func=lambda x: x,389 label_visibility="collapsed",390 )391 st.session_state.selected_property = property_name392 if len(matches) > 80:393 st.sidebar.caption("Showing first 80 matches. Type more to narrow.")394 else:395 themes = list(groups.keys())396 current_theme, _ = split_property_name(st.session_state.selected_property)397 theme_index = themes.index(current_theme) if current_theme in themes else 0398 theme = st.sidebar.selectbox("Theme", themes, index=theme_index)399 options = [name for _, name in groups[theme]]400 property_index = (401 options.index(st.session_state.selected_property)402 if st.session_state.selected_property in options403 else 0404 )405 property_name = st.sidebar.selectbox(406 "Property",407 options,408 index=property_index,409 format_func=lambda x: split_property_name(x)[1],410 )411 st.session_state.selected_property = property_name412 413 with st.sidebar.expander("Property metadata", expanded=False):414 item = meta.get(property_name, {})415 st.write("datatype:", item.get("datatype"))416 st.write("array:", item.get("is_array_valued"))417 st.write("null_fraction:", item.get("null_fraction"))418 st.write("source_datasets:", item.get("source_datasets"))419 description = item.get("description")420 if description:421 st.caption(description)422 423 return property_name, api_key, model, agent_enabled424 425 426def render_color_controls(property_name, property_meta, df):427 raw, values, mode = calculate_color_values(df, property_name, property_meta)428 if not mode.startswith("numeric"):429 return None430 431 non_null = values.dropna()432 if not len(non_null):433 st.sidebar.warning("No numeric values available for this property.")434 return None435 436 data_min = float(non_null.min())437 data_max = float(non_null.max())438 default_vmin = float(non_null.quantile(0.02))439 default_vmax = float(non_null.quantile(0.98))440 441 st.sidebar.subheader("Color scale")442 st.sidebar.caption("Scale is computed from all samples for the selected property, not from the current map view.")443 property_key = hashlib.md5(property_name.encode("utf-8")).hexdigest()[:12]444 use_full_range = st.sidebar.checkbox(445 "Use full data range",446 value=False,447 key=f"use_full_range_{property_key}",448 )449 if use_full_range:450 return data_min, data_max451 452 vmin = st.sidebar.number_input(453 "vmin",454 value=default_vmin,455 min_value=data_min,456 max_value=data_max,457 format="%.6g",458 key=f"vmin_{property_key}",459 )460 vmax = st.sidebar.number_input(461 "vmax",462 value=default_vmax,463 min_value=data_min,464 max_value=data_max,465 format="%.6g",466 key=f"vmax_{property_key}",467 )468 if vmax <= vmin:469 st.sidebar.warning("vmax must be larger than vmin; using percentile defaults.")470 return default_vmin, default_vmax471 return float(vmin), float(vmax)472 473 474def render_legend(legend):475 cols = st.columns(4)476 cols[0].metric("Mode", legend["mode"])477 cols[1].metric("Valid", f"{legend['valid']:,}")478 cols[2].metric("Missing", f"{legend['missing']:,}")479 480 if legend["mode"].startswith("numeric"):481 cols[3].metric("Range", "2%-98%")482 st.caption(483 f"Actual min/max: {legend['min']} / {legend['max']} | "484 f"color clamp: {legend['p02']} / {legend['p98']}"485 )486 else:487 cols[3].metric("Unique", f"{legend['unique']:,}")488 if legend["top_values"]:489 st.caption("Top categories: " + "; ".join(490 f"{k}: {v}" for k, v in legend["top_values"].items()491 ))492 493 494def render_colorbar(legend):495 if legend["mode"].startswith("numeric"):496 gradient = ", ".join(f"rgb({r}, {g}, {b})" for r, g, b in COLOR_STOPS)497 st.markdown(498 f"""499 <div style="margin-top: 0.75rem;">500 <div style="height: 14px; border-radius: 7px;501 background: linear-gradient(90deg, {gradient});"></div>502 <div style="display: flex; justify-content: space-between;503 font-size: 0.82rem; color: #666; margin-top: 0.2rem;">504 <span>vmin: {legend["vmin"]}</span>505 <span>vmax: {legend["vmax"]}</span>506 </div>507 </div>508 """,509 unsafe_allow_html=True,510 )511 else:512 top_values = legend.get("top_values", {})513 if not top_values:514 return515 swatches = []516 for value in top_values:517 r, g, b, _ = category_color(value)518 swatches.append(519 "<span style='display:inline-flex; align-items:center; gap:0.25rem; "520 "margin:0 0.65rem 0.35rem 0;'>"521 f"<span style='width:0.75rem; height:0.75rem; border-radius:50%; "522 f"background:rgb({r},{g},{b}); display:inline-block;'></span>"523 f"<span>{value}</span></span>"524 )525 st.markdown("".join(swatches), unsafe_allow_html=True)526 527 528def is_valid_display_value(value):529 text = str(value).strip()530 return text != "" and text.lower() not in {"nan", "none", "null"}531 532 533def format_overlap_line(row):534 sample = row.get("sample_id", row.get("id", ""))535 value = row.get("display_value", "")536 depth = row.get("SAMPLE_DEPTH_RANGE_CM", "")537 source = row.get("SAMPLE_SOURCE_DATASET", "")538 parts = [str(sample)]539 if is_valid_display_value(depth):540 parts.append(f"depth={depth}")541 if is_valid_display_value(source):542 parts.append(str(source))543 prefix = " | ".join(parts)544 return f"{prefix}: {value}"545 546 547def build_map_records(df):548 df = df.copy()549 identities = df["id"].map(parse_sample_identity)550 df["dataset_id"] = [item[0] for item in identities]551 df["point_id"] = [item[1] for item in identities]552 df["sample_id"] = [item[2] for item in identities]553 df["tooltip_location"] = df["GADM_NAMES"].fillna("").astype(str).str.replace(554 r"^[\[\]'\" ]+|[\[\]'\" ]+$",555 "",556 regex=True,557 )558 559 single_records = []560 overlap_records = []561 562 for point_id, group in df.groupby("point_id", sort=False):563 if len(group) == 1:564 row = group.iloc[0]565 single_records.append({566 "id": row["id"],567 "lon": float(row["lon"]),568 "lat": float(row["lat"]),569 "color": row["color"],570 "tooltip_text": (571 f"{row['id']}\n"572 f"{row['COUNTRY_CODE']} · {row['tooltip_location']}\n"573 f"{row['property']}\n"574 f"{row['display_value']}"575 ),576 })577 continue578 579 valid = group[group["display_value"].map(is_valid_display_value)]580 selected = valid.iloc[0] if len(valid) else group.iloc[0]581 lines = [format_overlap_line(row) for _, row in group.head(16).iterrows()]582 if len(group) > 16:583 lines.append(f"... {len(group) - 16} more samples")584 585 overlap_records.append({586 "id": f"{point_id} ({len(group)} samples)",587 "lon": float(selected["lon"]),588 "lat": float(selected["lat"]),589 "color": selected["color"],590 "tooltip_text": (591 f"Point {point_id}: {len(group)} samples\n"592 f"{selected['COUNTRY_CODE']} · {selected['tooltip_location']}\n"593 f"{selected['property']}\n"594 + "\n".join(lines)595 ),596 })597 598 return single_records, overlap_records599 600 601def render_map(df):602 viewport = st.session_state.get("viewport", DEFAULT_VIEWPORT)603 single_records, overlap_records = build_map_records(df)604 605 layers = []606 if single_records:607 layers.append(pdk.Layer(608 "ScatterplotLayer",609 data=single_records,610 get_position="[lon, lat]",611 get_fill_color="color",612 get_radius=1800,613 radius_min_pixels=2,614 radius_max_pixels=12,615 pickable=True,616 auto_highlight=True,617 ))618 619 if overlap_records:620 layers.append(pdk.Layer(621 "ScatterplotLayer",622 data=overlap_records,623 get_position="[lon, lat]",624 stroked=True,625 filled=True,626 get_fill_color="[0, 0, 0, 1]",627 get_line_color="color",628 get_radius=1800,629 radius_min_pixels=2,630 radius_max_pixels=12,631 line_width_min_pixels=3,632 pickable=True,633 auto_highlight=True,634 ))635 636 view_state = pdk.ViewState(637 longitude=viewport.get("lon", DEFAULT_VIEWPORT["lon"]),638 latitude=viewport.get("lat", DEFAULT_VIEWPORT["lat"]),639 zoom=viewport.get("zoom", DEFAULT_VIEWPORT["zoom"]),640 min_zoom=2,641 max_zoom=12,642 )643 644 tooltip = {"text": "{tooltip_text}"}645 646 deck = pdk.Deck(647 layers=layers,648 initial_view_state=view_state,649 map_style="light",650 tooltip=tooltip,651 )652 st.pydeck_chart(deck, use_container_width=True, height=MAP_HEIGHT_PX)653 if overlap_records:654 st.caption(655 f"{len(overlap_records):,} overlapping point markers are shown as rings. "656 "Their color uses the first sample in the group with a valid selected-property value."657 )658 659 660def core_property_prompt():661 lines = [662 f"- {item['label']}: {item['property']}"663 for item in CORE_UI_PROPERTIES664 ]665 return "\n".join(lines)666 667 668def strip_unit_suffix(property_name):669 text = str(property_name).strip()670 if text.endswith(")") and " (" in text:671 return text.rsplit(" (", 1)[0]672 return text673 674 675def normalize_property_text(text):676 return " ".join(str(text).strip().lower().split())677 678 679def resolve_property_name(candidate, valid_properties):680 if not candidate:681 return None, None682 683 candidate = str(candidate).strip()684 if candidate in valid_properties:685 return candidate, None686 687 normalized_candidate = normalize_property_text(candidate)688 valid_by_normalized = {689 normalize_property_text(prop): prop690 for prop in valid_properties691 }692 if normalized_candidate in valid_by_normalized:693 return valid_by_normalized[normalized_candidate], None694 695 valid_by_no_unit = {}696 for prop in valid_properties:697 key = normalize_property_text(strip_unit_suffix(prop))698 valid_by_no_unit.setdefault(key, []).append(prop)699 no_unit_matches = valid_by_no_unit.get(normalized_candidate, [])700 if len(no_unit_matches) == 1:701 return no_unit_matches[0], None702 if len(no_unit_matches) > 1:703 return None, f"ambiguous property without unit {candidate!r}: {no_unit_matches[:8]}"704 705 core_aliases = {}706 for item in CORE_UI_PROPERTIES:707 prop = item["property"]708 aliases = {709 item["label"],710 prop,711 strip_unit_suffix(prop),712 prop.split(":", 1)[-1],713 strip_unit_suffix(prop.split(":", 1)[-1]),714 }715 for alias in aliases:716 core_aliases.setdefault(normalize_property_text(alias), prop)717 718 if normalized_candidate in core_aliases:719 return core_aliases[normalized_candidate], None720 721 return None, f"unknown property {candidate!r}"722 723 724def viewport_prompt():725 lines = [726 f"- {name}: lat={view['lat']}, lon={view['lon']}, zoom={view['zoom']}"727 for name, view in CORE_VIEWPORTS.items()728 ]729 return "\n".join(lines)730 731 732def build_ui_agent_prompt(user_query, current_property, current_viewport):733 return f"""734You are a UI-control agent for the LUCAS-MEGA Fusion Viewer.735 736Your only job is to decide whether the user's query should update the UI.737Do not answer general knowledge questions. Do not explain soil science.738Return exactly one JSON object and nothing else.739 740If the query is unrelated to changing the map/property UI, return:741{{"need_update_ui": false, "property": null, "viewport_center": null, "viewport_bbox": null}}742 743If the query should update the UI, return:744{{745 "need_update_ui": true,746 "property": one of the allowed property strings or null,747 "viewport_center": {{"lat": number, "lon": number, "zoom": number}} or null,748 "viewport_bbox": null749}}750 751Allowed properties:752{core_property_prompt()}753 754Allowed named viewports:755{viewport_prompt()}756 757Rules:758- Pick the closest allowed property. Do not invent property names.759- The "property" value must be copied exactly from the allowed property strings, including units in parentheses.760- Never omit units from property names when units are present.761- For location requests, use the closest allowed named viewport when possible.762- If the user asks for a property but no location, update only "property".763- If the user asks for a location but no property, update only "viewport_center".764- If the user asks for both, update both.765- Use viewport_bbox only if you are certain; otherwise use viewport_center.766 767Current property: {current_property}768Current viewport: {json.dumps(current_viewport)}769User query: {user_query}770""".strip()771 772 773def call_ui_agent(api_key, model, user_query):774 try:775 from openai import OpenAI776 except ImportError:777 return None, "OpenAI SDK is not installed. Install it with: pip install openai"778 779 client = OpenAI(api_key=api_key)780 response = client.chat.completions.create(781 model=model,782 messages=[783 {784 "role": "user",785 "content": build_ui_agent_prompt(786 user_query=user_query,787 current_property=st.session_state.selected_property,788 current_viewport=st.session_state.viewport,789 ),790 }791 ],792 response_format={"type": "json_object"},793 )794 text = response.choices[0].message.content or "{}"795 try:796 return json.loads(text), None797 except json.JSONDecodeError as exc:798 return None, f"Could not parse UI-agent JSON: {exc}"799 800 801def validate_viewport(viewport):802 if not isinstance(viewport, dict):803 return None804 try:805 lat = float(viewport["lat"])806 lon = float(viewport["lon"])807 zoom = float(viewport["zoom"])808 except (KeyError, TypeError, ValueError):809 return None810 if not (-90 <= lat <= 90 and -180 <= lon <= 180 and 2 <= zoom <= 12):811 return None812 return {"lat": lat, "lon": lon, "zoom": zoom}813 814 815def apply_ui_agent_result(result, valid_properties):816 if not isinstance(result, dict):817 return False, "No need to update UI. General reasoning is under development."818 if not result.get("need_update_ui"):819 return False, "No need to update UI. General reasoning is under development."820 821 updates = []822 property_name = result.get("property")823 if property_name:824 resolved_property, property_error = resolve_property_name(property_name, valid_properties)825 if resolved_property:826 st.session_state.selected_property = resolved_property827 updates.append(f"property -> {resolved_property}")828 else:829 return False, f"UI update rejected: {property_error}"830 831 viewport = validate_viewport(result.get("viewport_center"))832 if viewport:833 st.session_state.viewport = viewport834 updates.append(835 f"viewport -> lat={viewport['lat']:.3f}, lon={viewport['lon']:.3f}, zoom={viewport['zoom']:.2f}"836 )837 838 if not updates:839 return False, "No need to update UI. General reasoning is under development."840 return True, "Updated UI: " + "; ".join(updates)841 842 843@st.fragment844def render_chat(api_key, model, valid_properties, agent_enabled):845 st.divider()846 st.subheader("UI Agent")847 848 for message in st.session_state.ui_agent_messages:849 with st.chat_message(message["role"]):850 st.write(message["content"])851 852 if not agent_enabled:853 st.text_input(854 "Ask the UI agent to change property or region",855 value="Enter an OpenAI API token in the sidebar to enable the UI agent.",856 disabled=True,857 label_visibility="collapsed",858 )859 return860 861 prompt = st.chat_input("Ask the UI agent to change property or region")862 if not prompt:863 return864 865 st.session_state.ui_agent_messages.append({"role": "user", "content": prompt})866 867 result, error = call_ui_agent(api_key.strip(), model.strip(), prompt)868 if error:869 answer = error870 should_rerun = False871 else:872 should_rerun, answer = apply_ui_agent_result(result, valid_properties)873 874 st.session_state.ui_agent_messages.append({"role": "assistant", "content": answer})875 if should_rerun:876 st.rerun()877 st.rerun(scope="fragment")878 879 880def main():881 st.set_page_config(882 page_title="Fusion Viewer",883 page_icon=str(ICON_PATH),884 layout="wide",885 initial_sidebar_state="expanded",886 )887 apply_compact_layout()888 889 init_ui_state()890 names, meta, groups = load_metadata()891 valid_properties = set(names)892 if st.session_state.selected_property not in valid_properties:893 st.session_state.selected_property = DEFAULT_PROPERTY894 895 sidebar_result = render_sidebar(groups, meta)896 if sidebar_result is None:897 return898 property_name, api_key, model, agent_enabled = sidebar_result899 900 st.title("Fusion Viewer")901 st.caption(f"{len(names):,} properties from datasets/fusion")902 903 with st.spinner("Loading selected property..."):904 df = load_property_frame(property_name)905 color_limits = render_color_controls(property_name, meta[property_name], df)906 vis_df, legend = prepare_visual_values(907 df,908 property_name,909 meta[property_name],910 color_limits=color_limits,911 )912 913 render_map(vis_df)914 render_colorbar(legend)915 render_legend(legend)916 render_chat(api_key, model, valid_properties, agent_enabled)917 918 919if __name__ == "__main__":920 main()921 