CoolFace
Datasetpublic

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.

sourceHugging Facemitupdated 3mo agoView on Hugging Face
0likes303downloads
viewer_fusion.py921 linesDownload Raw Back to root
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