|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations |
|
|
import os |
|
|
import io |
|
|
import json |
|
|
import math |
|
|
from math import floor |
|
|
from datetime import datetime |
|
|
from typing import Any, Dict, List, Tuple, Optional |
|
|
|
|
|
|
|
|
import streamlit as st |
|
|
|
|
|
|
|
|
st.set_page_config(page_title="GeoMate V2", page_icon="π", layout="wide", initial_sidebar_state="expanded") |
|
|
|
|
|
|
|
|
import numpy as np |
|
|
import pandas as pd |
|
|
import matplotlib.pyplot as plt |
|
|
import traceback |
|
|
|
|
|
|
|
|
try: |
|
|
import faiss |
|
|
except Exception: |
|
|
faiss = None |
|
|
|
|
|
try: |
|
|
import reportlab |
|
|
from reportlab.lib import colors |
|
|
from reportlab.lib.pagesizes import A4 |
|
|
from reportlab.lib.units import mm |
|
|
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak |
|
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle |
|
|
REPORTLAB_OK = True |
|
|
except Exception: |
|
|
REPORTLAB_OK = False |
|
|
|
|
|
|
|
|
try: |
|
|
from fpdf import FPDF |
|
|
FPDF_OK = True |
|
|
except Exception: |
|
|
FPDF_OK = False |
|
|
|
|
|
|
|
|
try: |
|
|
from groq import Groq |
|
|
GROQ_OK = True |
|
|
except Exception: |
|
|
GROQ_OK = False |
|
|
|
|
|
|
|
|
try: |
|
|
import ee |
|
|
import geemap |
|
|
EE_OK = True |
|
|
except Exception: |
|
|
ee = None |
|
|
geemap = None |
|
|
EE_OK = False |
|
|
|
|
|
|
|
|
OCR_TESSERACT = False |
|
|
try: |
|
|
import pytesseract |
|
|
from PIL import Image |
|
|
OCR_TESSERACT = True |
|
|
except Exception: |
|
|
OCR_TESSERACT = False |
|
|
|
|
|
|
|
|
ss = st.session_state |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def safe_rerun(): |
|
|
"""Try to rerun app. Prefer st.rerun(); fallback to experimental or simple stop.""" |
|
|
try: |
|
|
st.rerun() |
|
|
except Exception: |
|
|
try: |
|
|
st.experimental_rerun() |
|
|
except Exception: |
|
|
|
|
|
st.stop() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_secret(name: str) -> Optional[str]: |
|
|
"""Read secrets from environment variables first, then streamlit secrets.""" |
|
|
v = os.environ.get(name) |
|
|
if v: |
|
|
return v |
|
|
try: |
|
|
v2 = st.secrets.get(name) |
|
|
if v2: |
|
|
|
|
|
if isinstance(v2, dict) or isinstance(v2, list): |
|
|
|
|
|
return json.dumps(v2) |
|
|
return str(v2) |
|
|
except Exception: |
|
|
pass |
|
|
return None |
|
|
|
|
|
|
|
|
GROQ_KEY = get_secret("GROQ_API_KEY") |
|
|
SERVICE_ACCOUNT = get_secret("SERVICE_ACCOUNT") |
|
|
EARTH_ENGINE_KEY = get_secret("EARTH_ENGINE_KEY") |
|
|
|
|
|
HAVE_GROQ = bool(GROQ_KEY and GROQ_OK) |
|
|
HAVE_SERVICE_ACCOUNT = bool(SERVICE_ACCOUNT) |
|
|
HAVE_EE_KEY = bool(EARTH_ENGINE_KEY) |
|
|
|
|
|
EE_READY = False |
|
|
|
|
|
|
|
|
if EE_OK and (SERVICE_ACCOUNT or EARTH_ENGINE_KEY): |
|
|
try: |
|
|
|
|
|
key_file = None |
|
|
if EARTH_ENGINE_KEY: |
|
|
|
|
|
try: |
|
|
parsed = json.loads(EARTH_ENGINE_KEY) |
|
|
|
|
|
key_file = "/tmp/geomate_ee_key.json" |
|
|
with open(key_file, "w") as f: |
|
|
json.dump(parsed, f) |
|
|
except Exception: |
|
|
|
|
|
key_file = EARTH_ENGINE_KEY if os.path.exists(EARTH_ENGINE_KEY) else None |
|
|
if key_file and SERVICE_ACCOUNT: |
|
|
try: |
|
|
|
|
|
from oauth2client.service_account import ServiceAccountCredentials |
|
|
creds = ServiceAccountCredentials.from_json_keyfile_name(key_file, scopes=['https://www.googleapis.com/auth/earthengine']) |
|
|
ee.Initialize(creds) |
|
|
EE_READY = True |
|
|
except Exception: |
|
|
|
|
|
try: |
|
|
|
|
|
ee.Initialize() |
|
|
EE_READY = True |
|
|
except Exception: |
|
|
EE_READY = False |
|
|
else: |
|
|
|
|
|
try: |
|
|
ee.Initialize() |
|
|
EE_READY = True |
|
|
except Exception: |
|
|
EE_READY = False |
|
|
except Exception: |
|
|
EE_READY = False |
|
|
else: |
|
|
EE_READY = False |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if "site_descriptions" not in ss: |
|
|
|
|
|
ss["site_descriptions"] = [] |
|
|
if "active_site_index" not in ss: |
|
|
ss["active_site_index"] = 0 |
|
|
if "llm_model" not in ss: |
|
|
ss["llm_model"] = "llama3-8b-8192" |
|
|
if "page" not in ss: |
|
|
ss["page"] = "Landing" |
|
|
if "rag_memory" not in ss: |
|
|
ss["rag_memory"] = {} |
|
|
if "classifier_states" not in ss: |
|
|
ss["classifier_states"] = {} |
|
|
|
|
|
|
|
|
def make_empty_site(name: str = "Site 1") -> dict: |
|
|
return { |
|
|
"Site Name": name, |
|
|
"Site Coordinates": "", |
|
|
"lat": None, |
|
|
"lon": None, |
|
|
"Load Bearing Capacity": None, |
|
|
"Skin Shear Strength": None, |
|
|
"Relative Compaction": None, |
|
|
"Rate of Consolidation": None, |
|
|
"Nature of Construction": None, |
|
|
"Soil Profile": None, |
|
|
"Flood Data": None, |
|
|
"Seismic Data": None, |
|
|
"GSD": None, |
|
|
"USCS": None, |
|
|
"AASHTO": None, |
|
|
"GI": None, |
|
|
"classifier_inputs": {}, |
|
|
"classifier_decision_path": "", |
|
|
"chat_history": [], |
|
|
"report_convo_state": 0, |
|
|
"map_snapshot": None, |
|
|
"classifier_state": 0, |
|
|
"classifier_chat": [] |
|
|
} |
|
|
|
|
|
|
|
|
if len(ss["site_descriptions"]) == 0: |
|
|
ss["site_descriptions"].append(make_empty_site("Home")) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
from streamlit_option_menu import option_menu |
|
|
|
|
|
def sidebar_ui(): |
|
|
st.sidebar.markdown("<div style='text-align:center'><h2 style='color:#FF8C00;margin:6px 0'>GeoMate V2</h2></div>", unsafe_allow_html=True) |
|
|
st.sidebar.markdown("---") |
|
|
|
|
|
|
|
|
st.sidebar.subheader("LLM Model") |
|
|
model_options = ["llama3-8b-8192", "gemma-7b-it", "mixtral-8x7b-32768"] |
|
|
ss["llm_model"] = st.sidebar.selectbox("Select LLM model", model_options, index=model_options.index(ss.get("llm_model","llama3-8b-8192")), key="llm_select") |
|
|
|
|
|
st.sidebar.markdown("---") |
|
|
st.sidebar.subheader("Project Sites (max 4)") |
|
|
|
|
|
colA, colB = st.sidebar.columns([2,1]) |
|
|
with colA: |
|
|
new_site_name = st.text_input("New site name", value="", key="new_site_name_input") |
|
|
with colB: |
|
|
if st.button("β", key="add_site_btn"): |
|
|
|
|
|
if len(ss["site_descriptions"]) >= 4: |
|
|
st.sidebar.warning("Maximum 4 sites allowed.") |
|
|
else: |
|
|
name = new_site_name.strip() or f"Site {len(ss['site_descriptions'])+1}" |
|
|
ss["site_descriptions"].append(make_empty_site(name)) |
|
|
ss["active_site_index"] = len(ss["site_descriptions"]) - 1 |
|
|
safe_rerun() |
|
|
|
|
|
|
|
|
site_names = [s["Site Name"] for s in ss["site_descriptions"]] |
|
|
idx = st.sidebar.radio("Active Site", options=list(range(len(site_names))), format_func=lambda i: site_names[i], index=ss.get("active_site_index",0), key="active_site_radio") |
|
|
ss["active_site_index"] = idx |
|
|
|
|
|
|
|
|
if st.sidebar.button("ποΈ Remove Active Site", key="remove_site_btn"): |
|
|
if len(ss["site_descriptions"]) <= 1: |
|
|
st.sidebar.warning("Cannot remove last site.") |
|
|
else: |
|
|
ss["site_descriptions"].pop(ss["active_site_index"]) |
|
|
ss["active_site_index"] = max(0, ss["active_site_index"] - 1) |
|
|
safe_rerun() |
|
|
|
|
|
st.sidebar.markdown("---") |
|
|
st.sidebar.subheader("Active Site JSON") |
|
|
with st.sidebar.expander("Show site JSON", expanded=False): |
|
|
st.code(json.dumps(ss["site_descriptions"][ss["active_site_index"]], indent=2), language="json") |
|
|
|
|
|
st.sidebar.markdown("---") |
|
|
|
|
|
st.sidebar.subheader("Service Status") |
|
|
col1, col2 = st.sidebar.columns(2) |
|
|
col1.markdown("LLM:") |
|
|
col2.markdown("β
" if HAVE_GROQ else "β οΈ (no Groq)") |
|
|
col1.markdown("Earth Engine:") |
|
|
col2.markdown("β
" if EE_READY else "β οΈ (not initialized)") |
|
|
st.sidebar.markdown("---") |
|
|
|
|
|
pages = ["Landing", "Soil Recognizer", "Soil Classifier", "GSD Curve", "Locator", "GeoMate Ask", "Reports"] |
|
|
icons = ["house", "image", "flask", "bar-chart", "geo-alt", "robot", "file-earmark-text"] |
|
|
choice = option_menu(None, pages, icons=icons, menu_icon="cast", default_index=pages.index(ss.get("page","Landing")), orientation="vertical", styles={ |
|
|
"container": {"padding": "0px"}, |
|
|
"nav-link-selected": {"background-color": "#FF7A00"}, |
|
|
}) |
|
|
if choice and choice != ss.get("page"): |
|
|
ss["page"] = choice |
|
|
safe_rerun() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def landing_ui(): |
|
|
st.markdown( |
|
|
""" |
|
|
<style> |
|
|
.hero { |
|
|
background: linear-gradient(135deg,#0f0f0f 0%, #060606 100%); |
|
|
border-radius: 14px; |
|
|
padding: 20px; |
|
|
border: 1px solid rgba(255,122,0,0.08); |
|
|
} |
|
|
.glow-btn { |
|
|
background: linear-gradient(90deg,#ff7a00,#ff3a3a); |
|
|
color: white; |
|
|
padding: 10px 18px; |
|
|
border-radius: 10px; |
|
|
font-weight:700; |
|
|
box-shadow: 0 6px 24px rgba(255,122,0,0.12); |
|
|
border: none; |
|
|
} |
|
|
</style> |
|
|
""", unsafe_allow_html=True) |
|
|
st.markdown("<div class='hero'>", unsafe_allow_html=True) |
|
|
st.markdown("<h1 style='color:#FF8C00;margin:0'>π GeoMate V2</h1>", unsafe_allow_html=True) |
|
|
st.markdown("<p style='color:#ddd'>AI copilot for geotechnical engineering β soil recognition, classification, locator, RAG, professional reports.</p>", unsafe_allow_html=True) |
|
|
st.markdown("</div>", unsafe_allow_html=True) |
|
|
st.markdown("---") |
|
|
st.write("Quick actions") |
|
|
c1, c2, c3 = st.columns(3) |
|
|
if c1.button("πΌοΈ Soil Recognizer"): |
|
|
ss["page"] = "Soil Recognizer"; safe_rerun() |
|
|
if c2.button("π§ͺ Soil Classifier"): |
|
|
ss["page"] = "Soil Classifier"; safe_rerun() |
|
|
if c3.button("π GSD Curve"): |
|
|
ss["page"] = "GSD Curve"; safe_rerun() |
|
|
c4, c5, c6 = st.columns(3) |
|
|
if c4.button("π Locator"): |
|
|
ss["page"] = "Locator"; safe_rerun() |
|
|
if c5.button("π€ GeoMate Ask"): |
|
|
ss["page"] = "GeoMate Ask"; safe_rerun() |
|
|
if c6.button("π Reports"): |
|
|
ss["page"] = "Reports"; safe_rerun() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def active_site() -> Tuple[str, dict]: |
|
|
idx = ss.get("active_site_index", 0) |
|
|
idx = max(0, min(idx, len(ss["site_descriptions"]) - 1)) |
|
|
ss["active_site_index"] = idx |
|
|
site = ss["site_descriptions"][idx] |
|
|
return idx, site |
|
|
|
|
|
def save_site_field(field: str, value: Any): |
|
|
idx, site = active_site() |
|
|
site[field] = value |
|
|
ss["site_descriptions"][idx] = site |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def uscs_aashto_from_inputs(inputs: Dict[str,Any]) -> Tuple[str,str,str,int,Dict[str,str]]: |
|
|
""" |
|
|
Return: (result_text, uscs_symbol, aashto_symbol, GI, char_summary) |
|
|
""" |
|
|
|
|
|
ENGINEERING_CHARACTERISTICS = { |
|
|
"Gravel": { |
|
|
"Settlement": "None", |
|
|
"Quicksand": "Impossible", |
|
|
"Frost-heaving": "None", |
|
|
"Groundwater_lowering": "Possible", |
|
|
"Cement_grouting": "Possible", |
|
|
"Silicate_bitumen_injections": "Unsuitable", |
|
|
"Compressed_air": "Possible" |
|
|
}, |
|
|
"Coarse sand": {"Settlement":"None","Quicksand":"Impossible","Frost-heaving":"None"}, |
|
|
"Medium sand": {"Settlement":"None","Quicksand":"Unlikely"}, |
|
|
"Fine sand": {"Settlement":"None","Quicksand":"Liable"}, |
|
|
"Silt": {"Settlement":"Occurs","Quicksand":"Liable","Frost-heaving":"Occurs"}, |
|
|
"Clay": {"Settlement":"Occurs","Quicksand":"Impossible"} |
|
|
} |
|
|
|
|
|
opt = str(inputs.get("opt","n")).lower() |
|
|
if opt == 'y': |
|
|
uscs = "Pt" |
|
|
uscs_expl = "Peat / organic soil β compressible, high organic content; poor engineering properties for load-bearing without special treatment." |
|
|
aashto = "Organic (special handling)" |
|
|
GI = 0 |
|
|
result_text = f"According to USCS, the soil is {uscs} β {uscs_expl}\nAccording to AASHTO, the soil is {aashto}." |
|
|
return result_text, uscs, aashto, GI, {"summary":"Organic peat: large settlement, low strength."} |
|
|
|
|
|
|
|
|
P2 = float(inputs.get("P2", 0.0)) |
|
|
P4 = float(inputs.get("P4", 0.0)) |
|
|
D60 = float(inputs.get("D60", 0.0)) |
|
|
D30 = float(inputs.get("D30", 0.0)) |
|
|
D10 = float(inputs.get("D10", 0.0)) |
|
|
LL = float(inputs.get("LL", 0.0)) |
|
|
PL = float(inputs.get("PL", 0.0)) |
|
|
PI = LL - PL |
|
|
|
|
|
Cu = (D60 / D10) if (D10 > 0 and D60 > 0) else 0 |
|
|
Cc = ((D30 ** 2) / (D10 * D60)) if (D10 > 0 and D30 > 0 and D60 > 0) else 0 |
|
|
|
|
|
uscs = "Unknown"; uscs_expl = "" |
|
|
if P2 <= 50: |
|
|
|
|
|
if P4 <= 50: |
|
|
|
|
|
if Cu and Cc: |
|
|
if Cu >= 4 and 1 <= Cc <= 3: |
|
|
uscs, uscs_expl = "GW", "Well-graded gravel (good engineering properties, high strength, good drainage)." |
|
|
else: |
|
|
uscs, uscs_expl = "GP", "Poorly-graded gravel (less favorable gradation)." |
|
|
else: |
|
|
if PI < 4 or PI < 0.73 * (LL - 20): |
|
|
uscs, uscs_expl = "GM", "Silty gravel (fines may reduce permeability and strength)." |
|
|
elif PI > 7 and PI > 0.73 * (LL - 20): |
|
|
uscs, uscs_expl = "GC", "Clayey gravel (higher plasticity)." |
|
|
else: |
|
|
uscs, uscs_expl = "GM-GC", "Gravel with mixed silt/clay fines." |
|
|
else: |
|
|
|
|
|
if Cu and Cc: |
|
|
if Cu >= 6 and 1 <= Cc <= 3: |
|
|
uscs, uscs_expl = "SW", "Well-graded sand (good compaction and drainage)." |
|
|
else: |
|
|
uscs, uscs_expl = "SP", "Poorly-graded sand (uniform or gap-graded)." |
|
|
else: |
|
|
if PI < 4 or PI <= 0.73 * (LL - 20): |
|
|
uscs, uscs_expl = "SM", "Silty sand (low-plasticity fines)." |
|
|
elif PI > 7 and PI > 0.73 * (LL - 20): |
|
|
uscs, uscs_expl = "SC", "Clayey sand (clayey fines present)." |
|
|
else: |
|
|
uscs, uscs_expl = "SM-SC", "Transition between silty sand and clayey sand." |
|
|
else: |
|
|
|
|
|
nDS = int(inputs.get("nDS", 5)) |
|
|
nDIL = int(inputs.get("nDIL", 6)) |
|
|
nTG = int(inputs.get("nTG", 6)) |
|
|
if LL < 50: |
|
|
if 20 <= LL < 50 and PI <= 0.73 * (LL - 20): |
|
|
if nDS == 1 or nDIL == 3 or nTG == 3: |
|
|
uscs, uscs_expl = "ML", "Silt (low plasticity)." |
|
|
elif nDS == 3 or nDIL == 3 or nTG == 3: |
|
|
uscs, uscs_expl = "OL", "Organic silt (low plasticity)." |
|
|
else: |
|
|
uscs, uscs_expl = "ML-OL", "Mixed silt/organic silt." |
|
|
elif 10 <= LL <= 30 and 4 <= PI <= 7 and PI > 0.72 * (LL - 20): |
|
|
if nDS == 1 or nDIL == 1 or nTG == 1: |
|
|
uscs, uscs_expl = "ML", "Silt." |
|
|
elif nDS == 2 or nDIL == 2 or nTG == 2: |
|
|
uscs, uscs_expl = "CL", "Clay (low plasticity)." |
|
|
else: |
|
|
uscs, uscs_expl = "ML-CL", "Mixed silt/clay." |
|
|
else: |
|
|
uscs, uscs_expl = "CL", "Clay (low plasticity)." |
|
|
else: |
|
|
if PI < 0.73 * (LL - 20): |
|
|
if nDS == 3 or nDIL == 4 or nTG == 4: |
|
|
uscs, uscs_expl = "MH", "Silt (high plasticity)" |
|
|
elif nDS == 2 or nDIL == 2 or nTG == 4: |
|
|
uscs, uscs_expl = "OH", "Organic silt/clay (high plasticity)" |
|
|
else: |
|
|
uscs, uscs_expl = "MH-OH", "Mixed high-plasticity silt/organic" |
|
|
else: |
|
|
uscs, uscs_expl = "CH", "Clay (high plasticity)" |
|
|
|
|
|
|
|
|
if P2 <= 35: |
|
|
if P2 <= 15 and P4 <= 30 and PI <= 6: |
|
|
aashto = "A-1-a" |
|
|
elif P2 <= 25 and P4 <= 50 and PI <= 6: |
|
|
aashto = "A-1-b" |
|
|
elif P2 <= 35 and P4 > 0: |
|
|
if LL <= 40 and PI <= 10: |
|
|
aashto = "A-2-4" |
|
|
elif LL >= 41 and PI <= 10: |
|
|
aashto = "A-2-5" |
|
|
elif LL <= 40 and PI >= 11: |
|
|
aashto = "A-2-6" |
|
|
elif LL >= 41 and PI >= 11: |
|
|
aashto = "A-2-7" |
|
|
else: |
|
|
aashto = "A-2" |
|
|
else: |
|
|
aashto = "A-3" |
|
|
else: |
|
|
if LL <= 40 and PI <= 10: |
|
|
aashto = "A-4" |
|
|
elif LL >= 41 and PI <= 10: |
|
|
aashto = "A-5" |
|
|
elif LL <= 40 and PI >= 11: |
|
|
aashto = "A-6" |
|
|
else: |
|
|
aashto = "A-7-5" if PI <= (LL - 30) else "A-7-6" |
|
|
|
|
|
|
|
|
a = P2 - 35 |
|
|
a = 0 if a < 0 else (40 if a > 40 else a) |
|
|
b = P2 - 15 |
|
|
b = 0 if b < 0 else (40 if b > 40 else b) |
|
|
c = LL - 40 |
|
|
c = 0 if c < 0 else (20 if c > 20 else c) |
|
|
d = PI - 10 |
|
|
d = 0 if d < 0 else (20 if d > 20 else d) |
|
|
GI = floor(0.2 * a + 0.005 * a * c + 0.01 * b * d) |
|
|
|
|
|
aashto_expl = f"{aashto} (GI = {GI})" |
|
|
|
|
|
|
|
|
char_summary = ENGINEERING_CHARACTERISTICS.get("Silt", {}) |
|
|
if uscs.startswith(("G", "S")): |
|
|
char_summary = ENGINEERING_CHARACTERISTICS.get("Coarse sand", ENGINEERING_CHARACTERISTICS.get("Gravel")) |
|
|
if uscs.startswith(("M", "C", "O", "H")): |
|
|
char_summary = ENGINEERING_CHARACTERISTICS.get("Silt", {}) |
|
|
|
|
|
result_text = f"According to USCS, the soil is **{uscs}** β {uscs_expl}\n\nAccording to AASHTO, the soil is **{aashto_expl}**\n\nEngineering characteristics summary:\n" |
|
|
for k, v in char_summary.items(): |
|
|
result_text += f"- {k}: {v}\n" |
|
|
|
|
|
return result_text, uscs, aashto, GI, char_summary |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def gsd_curve_ui(): |
|
|
st.header("π Grain Size Distribution (GSD) Curve") |
|
|
_, site = active_site() |
|
|
st.markdown(f"**Active site:** {site['Site Name']}") |
|
|
|
|
|
st.info("Upload a CSV (two columns: diameter_mm, percent_passing) or enter diameters and % passing manually. I will compute D10, D30, D60 and save them for the active site.") |
|
|
|
|
|
col_up, col_manual = st.columns([1,1]) |
|
|
uploaded = None |
|
|
sieve = None; passing = None |
|
|
with col_up: |
|
|
uploaded = st.file_uploader("Upload CSV (diameter_mm, percent_passing)", type=["csv","txt"], key=f"gsd_upload_{site['Site Name']}") |
|
|
if uploaded: |
|
|
try: |
|
|
df = pd.read_csv(uploaded) |
|
|
|
|
|
numeric_cols = [c for c in df.columns if np.issubdtype(df[c].dtype, np.number)] |
|
|
if len(numeric_cols) >= 2: |
|
|
sieve = df[numeric_cols[0]].values.astype(float) |
|
|
passing = df[numeric_cols[1]].values.astype(float) |
|
|
else: |
|
|
|
|
|
sieve = df.iloc[:,0].astype(float).values |
|
|
passing = df.iloc[:,1].astype(float).values |
|
|
st.success("CSV loaded.") |
|
|
except Exception as e: |
|
|
st.error(f"Error reading CSV: {e}") |
|
|
sieve = passing = None |
|
|
|
|
|
with col_manual: |
|
|
diam_text = st.text_area("Diameters (mm) comma-separated (e.g. 75,50,37.5,...)", key=f"gsd_diams_{site['Site Name']}") |
|
|
pass_text = st.text_area("% Passing comma-separated (same order)", key=f"gsd_pass_{site['Site Name']}") |
|
|
if diam_text.strip() and pass_text.strip(): |
|
|
try: |
|
|
sieve = np.array([float(x.strip()) for x in diam_text.split(",") if x.strip()]) |
|
|
passing = np.array([float(x.strip()) for x in pass_text.split(",") if x.strip()]) |
|
|
except Exception as e: |
|
|
st.error(f"Invalid manual input: {e}") |
|
|
sieve = passing = None |
|
|
|
|
|
if sieve is None or passing is None: |
|
|
st.info("Provide GSD data above to compute D-values.") |
|
|
return |
|
|
|
|
|
|
|
|
order = np.argsort(-sieve) |
|
|
sieve = sieve[order]; passing = passing[order] |
|
|
|
|
|
if np.any(passing < 0) or np.any(passing > 100): |
|
|
st.warning("Some % passing values are outside 0-100. Please verify.") |
|
|
|
|
|
percent = passing.copy() |
|
|
if not np.all(np.diff(percent) >= 0): |
|
|
percent = percent[::-1]; sieve = sieve[::-1] |
|
|
|
|
|
percent = percent.astype(float); sieve = sieve.astype(float) |
|
|
|
|
|
def interp_d(pct: float) -> Optional[float]: |
|
|
try: |
|
|
|
|
|
return float(np.interp(pct, percent, sieve)) |
|
|
except Exception: |
|
|
return None |
|
|
D10 = interp_d(10.0); D30 = interp_d(30.0); D60 = interp_d(60.0) |
|
|
Cu = (D60 / D10) if (D10 and D60 and D10>0) else None |
|
|
Cc = (D30**2)/(D10*D60) if (D10 and D30 and D60 and D10>0) else None |
|
|
|
|
|
|
|
|
fig, ax = plt.subplots(figsize=(7,4)) |
|
|
ax.plot(sieve, passing, marker='o', label="% Passing") |
|
|
ax.set_xscale('log') |
|
|
ax.invert_xaxis() |
|
|
ax.set_xlabel("Particle diameter (mm) β log scale") |
|
|
ax.set_ylabel("% Passing") |
|
|
if D10: ax.axvline(D10, color='orange', linestyle='--', label=f"D10={D10:.4g} mm") |
|
|
if D30: ax.axvline(D30, color='red', linestyle='--', label=f"D30={D30:.4g} mm") |
|
|
if D60: ax.axvline(D60, color='blue', linestyle='--', label=f"D60={D60:.4g} mm") |
|
|
ax.grid(True, which='both', linestyle='--', linewidth=0.4) |
|
|
ax.legend() |
|
|
st.pyplot(fig) |
|
|
|
|
|
|
|
|
idx, sdict = active_site() |
|
|
sdict["GSD"] = { |
|
|
"sieve_mm": sieve.tolist(), |
|
|
"percent_passing": passing.tolist(), |
|
|
"D10": float(D10) if D10 is not None else None, |
|
|
"D30": float(D30) if D30 is not None else None, |
|
|
"D60": float(D60) if D60 is not None else None, |
|
|
"Cu": float(Cu) if Cu is not None else None, |
|
|
"Cc": float(Cc) if Cc is not None else None |
|
|
} |
|
|
ss["site_descriptions"][idx] = sdict |
|
|
st.success(f"Saved GSD to site: D10={D10}, D30={D30}, D60={D60}") |
|
|
if st.button("Copy D-values to Soil Classifier inputs (for this site)", key=f"copy_d_to_cls_{sdict['Site Name']}"): |
|
|
ss.setdefault("classifier_states", {}) |
|
|
ss["classifier_states"].setdefault(sdict["Site Name"], {"step":0, "inputs":{}}) |
|
|
ss["classifier_states"][sdict["Site Name"]]["inputs"]["D10"] = float(D10) if D10 is not None else 0.0 |
|
|
ss["classifier_states"][sdict["Site Name"]]["inputs"]["D30"] = float(D30) if D30 is not None else 0.0 |
|
|
ss["classifier_states"][sdict["Site Name"]]["inputs"]["D60"] = float(D60) if D60 is not None else 0.0 |
|
|
st.info("Copied. Go to Soil Classifier page and continue.") |
|
|
st.markdown("---") |
|
|
st.caption("Tip: If your classifier asks for D-values and you don't have them, compute them here and copy them back.") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def soil_classifier_ui(): |
|
|
st.header("π§ͺ Soil Classifier β Chatbot-style (smart inputs)") |
|
|
idx, sdict = active_site() |
|
|
site_name = sdict["Site Name"] |
|
|
|
|
|
|
|
|
ss.setdefault("classifier_states", {}) |
|
|
state = ss["classifier_states"].setdefault(site_name, {"step":0, "inputs":{}}) |
|
|
|
|
|
|
|
|
if sdict.get("GSD"): |
|
|
g = sdict["GSD"] |
|
|
state["inputs"].setdefault("D10", g.get("D10", 0.0)) |
|
|
state["inputs"].setdefault("D30", g.get("D30", 0.0)) |
|
|
state["inputs"].setdefault("D60", g.get("D60", 0.0)) |
|
|
|
|
|
|
|
|
dil_options = [ |
|
|
"1. Quick to slow", |
|
|
"2. None to very slow", |
|
|
"3. Slow", |
|
|
"4. Slow to none", |
|
|
"5. None", |
|
|
"6. Null?" |
|
|
] |
|
|
tough_options = [ |
|
|
"1. None", |
|
|
"2. Medium", |
|
|
"3. Slight?", |
|
|
"4. Slight to Medium?", |
|
|
"5. High", |
|
|
"6. Null?" |
|
|
] |
|
|
dry_options = [ |
|
|
"1. None to slight", |
|
|
"2. Medium to high", |
|
|
"3. Slight to Medium", |
|
|
"4. High to very high", |
|
|
"5. Null?" |
|
|
] |
|
|
|
|
|
|
|
|
if "classifier_mode" not in ss: |
|
|
ss["classifier_mode"] = {} |
|
|
ss["classifier_mode"].setdefault(site_name, "Both") |
|
|
mode = st.selectbox("Classification mode", ["Both", "USCS only", "AASHTO only"], index=["Both","USCS only","AASHTO only"].index(ss["classifier_mode"][site_name]), key=f"mode_{site_name}") |
|
|
ss["classifier_mode"][site_name] = mode |
|
|
|
|
|
|
|
|
step = state.get("step", 0) |
|
|
inputs = state.setdefault("inputs", {}) |
|
|
|
|
|
def goto(n): |
|
|
state["step"] = n |
|
|
ss["classifier_states"][site_name] = state |
|
|
safe_rerun() |
|
|
|
|
|
def save_input(key, value): |
|
|
inputs[key] = value |
|
|
state["inputs"] = inputs |
|
|
ss["classifier_states"][site_name] = state |
|
|
|
|
|
|
|
|
st.markdown(f"**Active site:** {site_name}") |
|
|
st.markdown("---") |
|
|
|
|
|
if step == 0: |
|
|
st.markdown("**GeoMate:** Hello! I'm the Soil Classifier bot. Shall we begin classification for this site?") |
|
|
c1, c2 = st.columns(2) |
|
|
if c1.button("Yes β Start", key=f"cls_start_{site_name}"): |
|
|
goto(1) |
|
|
if c2.button("Cancel", key=f"cls_cancel_{site_name}"): |
|
|
st.info("Classifier cancelled.") |
|
|
|
|
|
elif step == 1: |
|
|
st.markdown("**GeoMate:** Is the soil organic (contains high organic matter, spongy, odour)?") |
|
|
c1, c2 = st.columns(2) |
|
|
if c1.button("No (inorganic)", key=f"org_no_{site_name}"): |
|
|
save_input("opt", "n"); goto(2) |
|
|
if c2.button("Yes (organic)", key=f"org_yes_{site_name}"): |
|
|
save_input("opt", "y"); goto(12) |
|
|
|
|
|
elif step == 2: |
|
|
st.markdown("**GeoMate:** What is the percentage passing the #200 sieve (0.075 mm)?") |
|
|
val = st.number_input("Percentage passing #200 (P2)", value=float(inputs.get("P2",0.0)), min_value=0.0, max_value=100.0, key=f"P2_input_{site_name}", format="%.2f") |
|
|
c1, c2, c3 = st.columns([1,1,1]) |
|
|
if c1.button("Next", key=f"P2_next_{site_name}"): |
|
|
save_input("P2", float(val)); goto(3) |
|
|
if c2.button("Skip", key=f"P2_skip_{site_name}"): |
|
|
save_input("P2", 0.0); goto(3) |
|
|
if c3.button("Back", key=f"P2_back_{site_name}"): |
|
|
goto(0) |
|
|
|
|
|
elif step == 3: |
|
|
|
|
|
P2 = float(inputs.get("P2", 0.0)) |
|
|
if P2 > 50: |
|
|
st.markdown("**GeoMate:** P2 > 50 β fine-grained soil path selected.") |
|
|
if st.button("Continue (fine-grained)", key=f"cont_fine_{site_name}"): |
|
|
goto(4) |
|
|
else: |
|
|
st.markdown("**GeoMate:** P2 <= 50 β coarse-grained soil path selected.") |
|
|
if st.button("Continue (coarse-grained)", key=f"cont_coarse_{site_name}"): |
|
|
goto(6) |
|
|
|
|
|
|
|
|
elif step == 4: |
|
|
st.markdown("**GeoMate:** Enter Liquid Limit (LL).") |
|
|
val = st.number_input("Liquid Limit (LL)", value=float(inputs.get("LL",0.0)), min_value=0.0, max_value=200.0, key=f"LL_{site_name}", format="%.2f") |
|
|
col1, col2 = st.columns(2) |
|
|
if col1.button("Next", key=f"LL_next_{site_name}"): |
|
|
save_input("LL", float(val)); goto(5) |
|
|
if col2.button("Back", key=f"LL_back_{site_name}"): |
|
|
goto(3) |
|
|
|
|
|
elif step == 5: |
|
|
st.markdown("**GeoMate:** Enter Plastic Limit (PL).") |
|
|
val = st.number_input("Plastic Limit (PL)", value=float(inputs.get("PL",0.0)), min_value=0.0, max_value=200.0, key=f"PL_{site_name}", format="%.2f") |
|
|
col1, col2 = st.columns(2) |
|
|
if col1.button("Next", key=f"PL_next_{site_name}"): |
|
|
save_input("PL", float(val)); goto(11) |
|
|
if col2.button("Back", key=f"PL_back_{site_name}"): |
|
|
goto(4) |
|
|
|
|
|
|
|
|
elif step == 6: |
|
|
st.markdown("**GeoMate:** What is the % passing sieve no. 4 (4.75 mm)?") |
|
|
val = st.number_input("% passing #4 (P4)", value=float(inputs.get("P4",0.0)), min_value=0.0, max_value=100.0, key=f"P4_{site_name}", format="%.2f") |
|
|
c1, c2, c3 = st.columns([1,1,1]) |
|
|
if c1.button("Next", key=f"P4_next_{site_name}"): |
|
|
save_input("P4", float(val)); goto(7) |
|
|
if c2.button("Compute D-values (GSD page)", key=f"P4_gsd_{site_name}"): |
|
|
st.info("Please use GSD Curve page to compute D-values and then copy them back to classifier.") |
|
|
if c3.button("Back", key=f"P4_back_{site_name}"): |
|
|
goto(3) |
|
|
|
|
|
elif step == 7: |
|
|
st.markdown("**GeoMate:** Do you know D60, D30, D10 diameters (mm)?") |
|
|
c1, c2, c3 = st.columns([1,1,1]) |
|
|
if c1.button("Yes β enter values", key=f"dvals_yes_{site_name}"): |
|
|
goto(8) |
|
|
if c2.button("No β compute from GSD", key=f"dvals_no_{site_name}"): |
|
|
st.info("Use the GSD Curve page and then click 'Copy D-values to Soil Classifier' there.") |
|
|
if c3.button("Skip", key=f"dvals_skip_{site_name}"): |
|
|
save_input("D60", 0.0); save_input("D30", 0.0); save_input("D10", 0.0); goto(11) |
|
|
|
|
|
elif step == 8: |
|
|
st.markdown("**GeoMate:** Enter D60 (mm)") |
|
|
val = st.number_input("D60 (mm)", value=float(inputs.get("D60",0.0)), min_value=0.0, key=f"D60_{site_name}") |
|
|
if st.button("Next", key=f"D60_next_{site_name}"): |
|
|
save_input("D60", float(val)); goto(9) |
|
|
if st.button("Back", key=f"D60_back_{site_name}"): |
|
|
goto(7) |
|
|
|
|
|
elif step == 9: |
|
|
st.markdown("**GeoMate:** Enter D30 (mm)") |
|
|
val = st.number_input("D30 (mm)", value=float(inputs.get("D30",0.0)), min_value=0.0, key=f"D30_{site_name}") |
|
|
if st.button("Next", key=f"D30_next_{site_name}"): |
|
|
save_input("D30", float(val)); goto(10) |
|
|
if st.button("Back", key=f"D30_back_{site_name}"): |
|
|
goto(8) |
|
|
|
|
|
elif step == 10: |
|
|
st.markdown("**GeoMate:** Enter D10 (mm)") |
|
|
val = st.number_input("D10 (mm)", value=float(inputs.get("D10",0.0)), min_value=0.0, key=f"D10_{site_name}") |
|
|
if st.button("Next", key=f"D10_next_{site_name}"): |
|
|
save_input("D10", float(val)); goto(11) |
|
|
if st.button("Back", key=f"D10_back_{site_name}"): |
|
|
goto(9) |
|
|
|
|
|
|
|
|
elif step == 11: |
|
|
st.markdown("**GeoMate:** Fine soil descriptors (if applicable). You can skip.") |
|
|
sel_dry = st.selectbox("Dry strength", dry_options, index=min(2, len(dry_options)-1), key=f"dry_{site_name}") |
|
|
sel_dil = st.selectbox("Dilatancy", dil_options, index=0, key=f"dil_{site_name}") |
|
|
sel_tg = st.selectbox("Toughness", tough_options, index=0, key=f"tough_{site_name}") |
|
|
col1, col2, col3 = st.columns([1,1,1]) |
|
|
|
|
|
dry_map = {dry_options[i]: i+1 for i in range(len(dry_options))} |
|
|
dil_map = {dil_options[i]: i+1 for i in range(len(dil_options))} |
|
|
tough_map = {tough_options[i]: i+1 for i in range(len(tough_options))} |
|
|
if col1.button("Save & Continue", key=f"desc_save_{site_name}"): |
|
|
save_input("nDS", dry_map.get(sel_dry, 5)) |
|
|
save_input("nDIL", dil_map.get(sel_dil, 6)) |
|
|
save_input("nTG", tough_map.get(sel_tg, 6)) |
|
|
goto(12) |
|
|
if col2.button("Skip descriptors", key=f"desc_skip_{site_name}"): |
|
|
save_input("nDS", 5); save_input("nDIL", 6); save_input("nTG", 6); goto(12) |
|
|
if col3.button("Back", key=f"desc_back_{site_name}"): |
|
|
|
|
|
|
|
|
if inputs.get("LL") is not None and inputs.get("PL") is not None: |
|
|
goto(5) |
|
|
else: |
|
|
goto(6) |
|
|
|
|
|
elif step == 12: |
|
|
st.markdown("**GeoMate:** Ready to classify. Review inputs and press **Classify**.") |
|
|
st.json(inputs) |
|
|
col1, col2 = st.columns([1,1]) |
|
|
if col1.button("Classify", key=f"classify_now_{site_name}"): |
|
|
try: |
|
|
res_text, uscs_sym, aashto_sym, GI, char_summary = uscs_aashto_from_inputs(inputs) |
|
|
|
|
|
sdict["USCS"] = uscs_sym |
|
|
sdict["AASHTO"] = aashto_sym |
|
|
sdict["GI"] = GI |
|
|
sdict["classifier_inputs"] = inputs |
|
|
sdict["classifier_decision_path"] = res_text |
|
|
|
|
|
ss["site_descriptions"][ss["active_site_index"]] = sdict |
|
|
st.success("Classification complete and saved.") |
|
|
st.markdown("### Result") |
|
|
if mode == "USCS only": |
|
|
st.markdown(f"**USCS:** {uscs_sym}") |
|
|
elif mode == "AASHTO only": |
|
|
st.markdown(f"**AASHTO:** {aashto_sym} (GI={GI})") |
|
|
else: |
|
|
st.markdown(res_text) |
|
|
|
|
|
if FPDF_OK and st.button("Export Classification PDF", key=f"exp_cls_pdf_{site_name}"): |
|
|
pdf = FPDF() |
|
|
pdf.add_page() |
|
|
pdf.set_font("Arial", "B", 14) |
|
|
pdf.cell(0, 8, f"GeoMate Classification β {site_name}", ln=1) |
|
|
pdf.ln(4) |
|
|
pdf.set_font("Arial", "", 11) |
|
|
pdf.multi_cell(0, 7, res_text) |
|
|
pdf.ln(4) |
|
|
pdf.set_font("Arial", "B", 12) |
|
|
pdf.cell(0, 6, "Inputs:", ln=1) |
|
|
pdf.set_font("Arial", "", 10) |
|
|
for k, v in inputs.items(): |
|
|
pdf.cell(0,5, f"{k}: {v}", ln=1) |
|
|
fn = f"{site_name.replace(' ','_')}_classification.pdf" |
|
|
pdf.output(fn) |
|
|
with open(fn,"rb") as f: |
|
|
st.download_button("Download PDF", f, file_name=fn, mime="application/pdf") |
|
|
|
|
|
except Exception as e: |
|
|
st.error(f"Classification failed: {e}\n{traceback.format_exc()}") |
|
|
if col2.button("Back to edit", key=f"back_edit_{site_name}"): |
|
|
goto(11) |
|
|
|
|
|
else: |
|
|
st.warning("Classifier state reset.") |
|
|
state["step"] = 0 |
|
|
ss["classifier_states"][site_name] = state |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def locator_ui(): |
|
|
st.header("π Locator β define AOI (GeoJSON or coordinates)") |
|
|
idx, sdict = active_site() |
|
|
site_name = sdict["Site Name"] |
|
|
st.markdown(f"**Active site:** {site_name}") |
|
|
st.info("You can paste GeoJSON for your area of interest or enter a center point (lat, lon). The app will save the AOI to the active site and try to display a map.") |
|
|
|
|
|
|
|
|
choice = st.radio("Provide:", ["GeoJSON (polygon)", "Coordinates (lat, lon)"], index=0, key=f"loc_choice_{site_name}") |
|
|
if choice.startswith("GeoJSON"): |
|
|
geo_text = st.text_area("Paste GeoJSON (Polygon or MultiPolygon)", value=sdict.get("map_snapshot") or "", key=f"geojson_area_{site_name}", height=180) |
|
|
if st.button("Save AOI and show map", key=f"save_aoi_{site_name}"): |
|
|
if not geo_text.strip(): |
|
|
st.error("No GeoJSON provided.") |
|
|
else: |
|
|
try: |
|
|
gj = json.loads(geo_text) |
|
|
sdict["map_snapshot"] = gj |
|
|
ss["site_descriptions"][idx] = sdict |
|
|
st.success("AOI saved.") |
|
|
|
|
|
|
|
|
def centroid_of_geojson(gj_obj): |
|
|
coords = [] |
|
|
if gj_obj.get("type") == "FeatureCollection": |
|
|
for f in gj_obj.get("features", []): |
|
|
geom = f.get("geometry", {}) |
|
|
coords.extend(_extract_coords_from_geom(geom)) |
|
|
else: |
|
|
geom = gj_obj if "geometry" not in gj_obj else gj_obj["geometry"] |
|
|
coords.extend(_extract_coords_from_geom(geom)) |
|
|
if not coords: |
|
|
return None |
|
|
arr = np.array(coords) |
|
|
return float(arr[:,1].mean()), float(arr[:,0].mean()) |
|
|
def _extract_coords_from_geom(geom): |
|
|
if not geom: |
|
|
return [] |
|
|
t = geom.get("type","") |
|
|
if t == "Polygon": |
|
|
return [tuple(pt) for pt in geom.get("coordinates", [])[0]] |
|
|
if t == "MultiPolygon": |
|
|
pts=[] |
|
|
for poly in geom.get("coordinates", []): |
|
|
pts.extend([tuple(pt) for pt in poly[0]]) |
|
|
return pts |
|
|
if t == "Point": |
|
|
return [tuple(geom.get("coordinates",[]))] |
|
|
return [] |
|
|
cent = centroid_of_geojson(gj) |
|
|
if cent: |
|
|
latc, lonc = cent |
|
|
sdict["lat"] = latc; sdict["lon"] = lonc |
|
|
ss["site_descriptions"][idx] = sdict |
|
|
st.map(pd.DataFrame({"lat":[latc],"lon":[lonc]})) |
|
|
else: |
|
|
st.info("Could not compute centroid for map preview, but AOI saved.") |
|
|
except Exception as e: |
|
|
st.error(f"Invalid GeoJSON: {e}") |
|
|
else: |
|
|
|
|
|
lat = st.number_input("Latitude", value=float(sdict.get("lat") or 0.0), key=f"loc_lat_{site_name}") |
|
|
lon = st.number_input("Longitude", value=float(sdict.get("lon") or 0.0), key=f"loc_lon_{site_name}") |
|
|
if st.button("Save coordinates and show map", key=f"save_coords_{site_name}"): |
|
|
sdict["lat"] = float(lat); sdict["lon"] = float(lon) |
|
|
ss["site_descriptions"][idx] = sdict |
|
|
st.success("Coordinates saved.") |
|
|
st.map(pd.DataFrame({"lat":[lat],"lon":[lon]})) |
|
|
|
|
|
|
|
|
st.markdown("---") |
|
|
if EE_READY: |
|
|
if st.button("Fetch Earth Engine data (soil profile / climate / flood / seismic) β experimental"): |
|
|
st.info("Earth Engine available β fetching (placeholder).") |
|
|
|
|
|
try: |
|
|
|
|
|
sdict["Soil Profile"] = "Placeholder Earth Engine soil profile data (EE initialized)." |
|
|
sdict["Flood Data"] = "Placeholder flood history (20 years) from EE." |
|
|
sdict["Seismic Data"] = "Placeholder seismic history (20 years) from EE." |
|
|
ss["site_descriptions"][idx] = sdict |
|
|
st.success("Earth Engine data fetched and saved to site (placeholder).") |
|
|
except Exception as e: |
|
|
st.error(f"EE fetch failed: {e}") |
|
|
else: |
|
|
st.info("Earth Engine not initialized in this runtime. To enable, set SERVICE_ACCOUNT and EARTH_ENGINE_KEY secrets and ensure earthengine-api is installed.") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_llm_completion(prompt: str, model: str = "llama3-8b-8192") -> str: |
|
|
"""Minimal LLM wrapper: uses Groq if available; else returns a dummy but structured answer.""" |
|
|
if GROQ_OK and GROQ_KEY: |
|
|
try: |
|
|
client = Groq(api_key=GROQ_KEY) |
|
|
comp = client.chat.completions.create(model=model, messages=[{"role":"user", "content": prompt}], temperature=0.2, max_tokens=800) |
|
|
return comp.choices[0].message.content |
|
|
except Exception as e: |
|
|
return f"(Groq error) {e}" |
|
|
else: |
|
|
|
|
|
return f"(Dummy LLM) I received your prompt and would reply here. Model: {model}\n\nPrompt excerpt:\n{prompt[:400]}" |
|
|
|
|
|
def rag_ui(): |
|
|
st.header("π€ GeoMate Ask β RAG Chatbot (per-site memory)") |
|
|
idx, sdict = active_site() |
|
|
site_name = sdict["Site Name"] |
|
|
st.markdown(f"**Active site:** {site_name}") |
|
|
|
|
|
ss.setdefault("rag_memory", {}) |
|
|
chat = ss["rag_memory"].setdefault(site_name, []) |
|
|
|
|
|
for turn in chat[-40:]: |
|
|
role, text = turn.get("role"), turn.get("text") |
|
|
if role == "user": |
|
|
st.markdown(f"**You:** {text}") |
|
|
else: |
|
|
st.markdown(f"**GeoMate:** {text}") |
|
|
|
|
|
|
|
|
user_prompt = st.text_input("Ask GeoMate (technical)", key=f"rag_input_{site_name}") |
|
|
col1, col2 = st.columns([3,1]) |
|
|
if col1.button("Send", key=f"rag_send_{site_name}") and user_prompt.strip(): |
|
|
|
|
|
chat.append({"role":"user", "text":user_prompt, "ts": datetime.now().isoformat()}) |
|
|
ss["rag_memory"][site_name] = chat |
|
|
|
|
|
context = {"site": sdict} |
|
|
full_prompt = f"Site data (json):\n{json.dumps(context, indent=2)}\n\nUser question:\n{user_prompt}\nPlease answer technically." |
|
|
|
|
|
with st.spinner("Running LLM..."): |
|
|
resp = run_llm_completion(full_prompt, model=ss.get("llm_model")) |
|
|
|
|
|
chat.append({"role":"assistant", "text":resp, "ts": datetime.now().isoformat()}) |
|
|
ss["rag_memory"][site_name] = chat |
|
|
|
|
|
update_site_description_from_chat(resp, site_name) |
|
|
safe_rerun() |
|
|
|
|
|
|
|
|
def update_site_description_from_chat(text: str, site_name: str): |
|
|
"""Naive extraction: looks for keywords like 'bearing' and a numeric value followed by units.""" |
|
|
idx = None |
|
|
for i, s in enumerate(ss["site_descriptions"]): |
|
|
if s["Site Name"] == site_name: |
|
|
idx = i; break |
|
|
if idx is None: |
|
|
return |
|
|
|
|
|
lowered = text.lower() |
|
|
site = ss["site_descriptions"][idx] |
|
|
|
|
|
import re |
|
|
m = re.search(r"bearing.*?([0-9]{2,6})\s*(psf|kpa|kpa\.)?", lowered) |
|
|
if m: |
|
|
val = m.group(1) |
|
|
site["Load Bearing Capacity"] = m.group(0) |
|
|
ss["site_descriptions"][idx] = site |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_classification_pdf_bytes(site_dict: dict) -> bytes: |
|
|
"""Return bytes of a classification-only PDF for a single site. Try ReportLab then FPDF fallback.""" |
|
|
text = site_dict.get("classifier_decision_path") or "No classification decision path available." |
|
|
inputs = site_dict.get("classifier_inputs", {}) |
|
|
title = f"GeoMate Classification Report β {site_dict['Site Name']}" |
|
|
|
|
|
if REPORTLAB_OK: |
|
|
buf = io.BytesIO() |
|
|
doc = SimpleDocTemplate(buf, pagesize=A4) |
|
|
styles = getSampleStyleSheet() |
|
|
elems = [] |
|
|
elems.append(Paragraph(title, styles["Title"])) |
|
|
elems.append(Spacer(1,6)) |
|
|
elems.append(Paragraph("Classification result and explanation:", styles["Heading2"])) |
|
|
elems.append(Paragraph(text.replace("\n","<br/>"), styles["BodyText"])) |
|
|
elems.append(Spacer(1,6)) |
|
|
elems.append(Paragraph("Inputs:", styles["Heading3"])) |
|
|
for k,v in inputs.items(): |
|
|
elems.append(Paragraph(f"{k}: {v}", styles["BodyText"])) |
|
|
doc.build(elems) |
|
|
pdf_bytes = buf.getvalue(); buf.close() |
|
|
return pdf_bytes |
|
|
elif FPDF_OK: |
|
|
pdf = FPDF() |
|
|
pdf.add_page() |
|
|
pdf.set_font("Arial", "B", 14) |
|
|
pdf.cell(0, 10, title, ln=1) |
|
|
pdf.set_font("Arial", "", 11) |
|
|
pdf.multi_cell(0, 6, text) |
|
|
pdf.ln(4) |
|
|
pdf.set_font("Arial", "B", 12) |
|
|
pdf.cell(0,6,"Inputs:", ln=1) |
|
|
pdf.set_font("Arial", "", 10) |
|
|
for k,v in inputs.items(): |
|
|
pdf.cell(0,5,f"{k}: {v}", ln=1) |
|
|
out = pdf.output(dest="S").encode("latin-1") |
|
|
return out |
|
|
else: |
|
|
|
|
|
return ("Classification:\n"+text+"\n\nInputs:\n"+json.dumps(inputs, indent=2)).encode("utf-8") |
|
|
|
|
|
def build_full_phase1_pdf_bytes(site_list: List[dict], ext_refs: List[str]) -> bytes: |
|
|
""" |
|
|
Build full geotechnical report (Phase 1) combining site data, classifier results, GSD and maps (if any). |
|
|
This is a streamlined generation: for production you would expand each analysis section. |
|
|
""" |
|
|
title = "GeoMate β Full Geotechnical Investigation Report" |
|
|
|
|
|
if REPORTLAB_OK: |
|
|
buf = io.BytesIO() |
|
|
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=20*mm, rightMargin=20*mm, topMargin=20*mm) |
|
|
styles = getSampleStyleSheet() |
|
|
elems = [] |
|
|
elems.append(Paragraph(title, styles["Title"])) |
|
|
elems.append(Spacer(1,8)) |
|
|
elems.append(Paragraph(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}", styles["Normal"])) |
|
|
elems.append(Spacer(1,12)) |
|
|
|
|
|
elems.append(Paragraph("SUMMARY", styles["Heading2"])) |
|
|
elems.append(Paragraph("This full geotechnical report was generated by GeoMate V2. The following pages contain site descriptions, classification results and recommendations.", styles["BodyText"])) |
|
|
elems.append(PageBreak()) |
|
|
|
|
|
for s in site_list: |
|
|
elems.append(Paragraph(f"SITE: {s.get('Site Name','Unnamed')}", styles["Heading1"])) |
|
|
elems.append(Paragraph(f"Location: {s.get('Site Coordinates','Not provided')}", styles["BodyText"])) |
|
|
elems.append(Spacer(1,6)) |
|
|
|
|
|
elems.append(Paragraph("Classification", styles["Heading2"])) |
|
|
elems.append(Paragraph(s.get("classifier_decision_path","No classification available.").replace("\n","<br/>"), styles["BodyText"])) |
|
|
elems.append(Spacer(1,6)) |
|
|
|
|
|
if s.get("GSD"): |
|
|
elems.append(Paragraph("GSD Summary", styles["Heading2"])) |
|
|
g = s["GSD"] |
|
|
tdata = [["D10 (mm)", "D30 (mm)", "D60 (mm)", "Cu", "Cc"]] |
|
|
tdata.append([str(g.get("D10")), str(g.get("D30")), str(g.get("D60")), str(g.get("Cu")), str(g.get("Cc"))]) |
|
|
t = Table(tdata, colWidths=[30*mm]*5) |
|
|
t.setStyle(TableStyle([("GRID",(0,0),(-1,-1),0.5,colors.grey),("BACKGROUND",(0,0),(-1,0),colors.HexColor("#1F4E79")),("TEXTCOLOR",(0,0),(-1,0),colors.white)])) |
|
|
elems.append(t) |
|
|
elems.append(PageBreak()) |
|
|
|
|
|
elems.append(Paragraph("External References", styles["Heading2"])) |
|
|
for r in ext_refs: |
|
|
elems.append(Paragraph(r, styles["BodyText"])) |
|
|
doc.build(elems) |
|
|
pdf_bytes = buf.getvalue(); buf.close() |
|
|
return pdf_bytes |
|
|
elif FPDF_OK: |
|
|
pdf = FPDF() |
|
|
pdf.add_page() |
|
|
pdf.set_font("Arial", "B", 16) |
|
|
pdf.cell(0, 10, title, ln=1) |
|
|
pdf.set_font("Arial", "", 11) |
|
|
pdf.ln(4) |
|
|
for s in site_list: |
|
|
pdf.set_font("Arial", "B", 13) |
|
|
pdf.cell(0, 8, f"Site: {s.get('Site Name','')}", ln=1) |
|
|
pdf.set_font("Arial", "", 11) |
|
|
pdf.multi_cell(0, 6, s.get("classifier_decision_path","No classification data.")) |
|
|
pdf.ln(4) |
|
|
if s.get("GSD"): |
|
|
g = s["GSD"] |
|
|
pdf.cell(0, 6, f"GSD D10={g.get('D10')}, D30={g.get('D30')}, D60={g.get('D60')}", ln=1) |
|
|
pdf.add_page() |
|
|
out = pdf.output(dest="S").encode("latin-1") |
|
|
return out |
|
|
else: |
|
|
|
|
|
parts = [title, "Generated: "+datetime.now().isoformat()] |
|
|
for s in site_list: |
|
|
parts.append("SITE: "+s.get("Site Name","")) |
|
|
parts.append("Classification:\n"+(s.get("classifier_decision_path") or "No data")) |
|
|
parts.append("GSD: "+(json.dumps(s.get("GSD") or {}))) |
|
|
parts.append("REFERENCES: "+json.dumps(ext_refs)) |
|
|
return ("\n\n".join(parts)).encode("utf-8") |
|
|
|
|
|
|
|
|
def reports_ui(): |
|
|
st.header("π Reports β Classification-only & Full Geotechnical Report") |
|
|
idx, sdict = active_site() |
|
|
st.subheader("Classification-only Report") |
|
|
st.markdown("Generates a PDF containing the classification result and the decision path for the selected site.") |
|
|
if st.button("Generate Classification PDF", key=f"gen_cls_pdf_{sdict['Site Name']}"): |
|
|
pdf_bytes = build_classification_pdf_bytes(sdict) |
|
|
st.download_button("Download Classification PDF", data=pdf_bytes, file_name=f"{sdict['Site Name']}_classification.pdf", mime="application/pdf") |
|
|
st.markdown("---") |
|
|
st.subheader("Full Geotechnical Report") |
|
|
st.markdown("Chatbot will gather missing parameters and produce a full Phase 1 report for selected sites (up to 4).") |
|
|
|
|
|
all_site_names = [s["Site Name"] for s in ss["site_descriptions"]] |
|
|
chosen = st.multiselect("Select sites to include", options=all_site_names, default=all_site_names, key="report_sites_select") |
|
|
ext_refs_text = st.text_area("External references (one per line)", key="ext_refs") |
|
|
|
|
|
if st.button("Start conversational data gather for Full Report", key="start_report_convo"): |
|
|
|
|
|
required_questions = [ |
|
|
("Load Bearing Capacity", "What is the soil bearing capacity (e.g., 2000 psf or 'Don't know')?"), |
|
|
("Skin Shear Strength", "Provide skin shear strength (kPa) if known, else 'Don't know'"), |
|
|
("Relative Compaction", "Relative compaction (%)"), |
|
|
("Rate of Consolidation", "Rate of consolidation (e.g., cv in m2/year)"), |
|
|
("Nature of Construction", "Nature of construction (e.g., 2-storey residence)"), |
|
|
("Other", "Any other relevant notes or 'None'") |
|
|
] |
|
|
|
|
|
for site_name in chosen: |
|
|
st.info(f"Collecting data for site: {site_name}") |
|
|
|
|
|
site_idx = [i for i,s in enumerate(ss["site_descriptions"]) if s["Site Name"]==site_name][0] |
|
|
site_obj = ss["site_descriptions"][site_idx] |
|
|
for field, q in required_questions: |
|
|
|
|
|
current_val = site_obj.get(field) |
|
|
if current_val: |
|
|
st.write(f"{field} (existing): {current_val}") |
|
|
keep = st.radio(f"Keep existing {field} for {site_name}?", ["Keep","Replace"], key=f"keep_{site_name}_{field}") |
|
|
if keep == "Keep": |
|
|
continue |
|
|
ans = st.text_input(q, key=f"report_q_{site_name}_{field}") |
|
|
if ans.strip().lower() in ["don't know","dont know","skip","n","no","unknown",""]: |
|
|
site_obj[field] = None |
|
|
else: |
|
|
site_obj[field] = ans.strip() |
|
|
ss["site_descriptions"][site_idx] = site_obj |
|
|
st.success(f"Data saved for {site_name}.") |
|
|
st.info("Conversational gather complete. Use Generate Full Report PDF button to create the PDF.") |
|
|
|
|
|
if st.button("Generate Full Report PDF", key="gen_full_pdf_btn"): |
|
|
|
|
|
site_objs = [s for s in ss["site_descriptions"] if s["Site Name"] in chosen] |
|
|
ext_refs = [r.strip() for r in ext_refs_text.splitlines() if r.strip()] |
|
|
pdf_bytes = build_full_phase1_pdf_bytes(site_objs, ext_refs) |
|
|
st.download_button("Download Full Geotechnical Report", data=pdf_bytes, file_name=f"GeoMate_Full_Report_{datetime.now().strftime('%Y%m%d')}.pdf", mime="application/pdf") |
|
|
|
|
|
|
|
|
|
|
|
def soil_recognizer_ui(): |
|
|
st.header("πΌοΈ Soil Recognizer (image-based)") |
|
|
st.info("Upload a soil image (photo of sample or field). If an offline model 'soil_best_model.pth' exists in repo, it will be used. Otherwise this page acts as a placeholder for integrating your ML model or API.") |
|
|
uploaded = st.file_uploader("Upload soil image (jpg/png)", type=["jpg","jpeg","png"], key="sr_upload") |
|
|
if uploaded: |
|
|
st.image(uploaded, caption="Uploaded image", use_column_width=True) |
|
|
|
|
|
st.warning("Model inference not configured in this Space. To enable, upload 'soil_best_model.pth' and implement model loading code here.") |
|
|
if OCR_TESSERACT: |
|
|
st.info("Attempting OCR of image (to extract printed text)") |
|
|
try: |
|
|
img = Image.open(uploaded) |
|
|
text = pytesseract.image_to_string(img) |
|
|
st.text_area("Extracted text (OCR)", value=text, height=200) |
|
|
except Exception as e: |
|
|
st.error(f"OCR failed: {e}") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(): |
|
|
sidebar_ui() |
|
|
|
|
|
page = ss.get("page", "Landing") |
|
|
if page == "Landing": |
|
|
landing_ui() |
|
|
elif page == "Soil Recognizer": |
|
|
soil_recognizer_ui() |
|
|
elif page == "Soil Classifier": |
|
|
soil_classifier_ui() |
|
|
elif page == "GSD Curve": |
|
|
gsd_curve_ui() |
|
|
elif page == "Locator": |
|
|
locator_ui() |
|
|
elif page == "GeoMate Ask": |
|
|
rag_ui() |
|
|
elif page == "Reports": |
|
|
reports_ui() |
|
|
else: |
|
|
st.write("Page not found.") |
|
|
|
|
|
if __name__ == "__main__": |
|
|
main() |
|
|
|