MSU576 commited on
Commit
6f58488
·
verified ·
1 Parent(s): fc904c6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +374 -1112
app.py CHANGED
@@ -1,461 +1,324 @@
1
- # app.py — GeoMate V2 (single-file Streamlit app)
2
- # ------------------------------------------------
3
- # Paste this entire file into your Hugging Face Space as app.py
4
- # ------------------------------------------------
 
 
 
5
 
6
- from __future__ import annotations
7
  import os
8
  import io
 
9
  import json
10
  import math
11
- from math import floor
 
12
  from datetime import datetime
13
- from typing import Any, Dict, List, Tuple, Optional
14
 
15
- # Streamlit must be imported early and set_page_config must be first Streamlit command
16
  import streamlit as st
17
 
18
- # Page config (first Streamlit call)
19
- st.set_page_config(page_title="GeoMate V2", page_icon="🌍", layout="wide", initial_sidebar_state="expanded")
20
-
21
- # Standard libs for data & plotting
22
  import numpy as np
23
  import pandas as pd
24
  import matplotlib.pyplot as plt
25
- import traceback
26
 
27
- # Optional heavy libs — import safely
 
 
 
 
 
 
 
 
 
 
28
  try:
29
- import faiss # may fail in some environments
 
30
  except Exception:
31
- faiss = None
32
 
33
  try:
34
- import reportlab
35
- from reportlab.lib import colors
36
- from reportlab.lib.pagesizes import A4
37
- from reportlab.lib.units import mm
38
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak
39
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
40
- REPORTLAB_OK = True
41
  except Exception:
42
- REPORTLAB_OK = False
43
 
44
- # FPDF fallback for simple PDFs
45
  try:
46
- from fpdf import FPDF
47
- FPDF_OK = True
48
  except Exception:
49
- FPDF_OK = False
50
 
51
- # Groq client optional
52
  try:
53
  from groq import Groq
54
- GROQ_OK = True
55
  except Exception:
56
- GROQ_OK = False
57
 
58
- # Earth Engine & geemap optional
59
  try:
60
- import ee
61
- import geemap
62
- EE_OK = True
63
  except Exception:
64
- ee = None
65
- geemap = None
66
- EE_OK = False
67
 
68
- # OCR optional (pytesseract / easyocr)
69
- OCR_TESSERACT = False
70
- try:
71
- import pytesseract
72
- from PIL import Image
73
- OCR_TESSERACT = True
74
- except Exception:
75
- OCR_TESSERACT = False
76
 
77
- # Helpful alias for session state
78
  ss = st.session_state
79
 
80
- # -------------------------
81
- # Helper: safe rerun
82
- # -------------------------
83
- def safe_rerun():
84
- """Try to rerun app. Prefer st.rerun(); fallback to experimental or simple stop."""
85
- try:
86
- st.rerun()
87
- except Exception:
88
- try:
89
- st.experimental_rerun()
90
- except Exception:
91
- # last resort: stop execution so user can click/refresh
92
- st.stop()
93
-
94
- # -------------------------
95
- # Secrets: HF-friendly access
96
- # -------------------------
97
- def get_secret(name: str) -> Optional[str]:
98
- """Read secrets from environment variables first, then streamlit secrets."""
99
- v = os.environ.get(name)
100
- if v:
101
- return v
102
- try:
103
- v2 = st.secrets.get(name)
104
- if v2:
105
- # st.secrets may contain nested dicts (for JSON keys)
106
- if isinstance(v2, dict) or isinstance(v2, list):
107
- # convert to JSON string
108
- return json.dumps(v2)
109
- return str(v2)
110
- except Exception:
111
- pass
112
- return None
113
-
114
- # Required secret names (user asked)
115
- GROQ_KEY = get_secret("GROQ_API_KEY")
116
- SERVICE_ACCOUNT = get_secret("SERVICE_ACCOUNT")
117
- EARTH_ENGINE_KEY = get_secret("EARTH_ENGINE_KEY") # JSON content (string) or path; optional
118
- # We'll consider them optional; pages will show clear errors if missing
119
- HAVE_GROQ = bool(GROQ_KEY and GROQ_OK)
120
- HAVE_SERVICE_ACCOUNT = bool(SERVICE_ACCOUNT)
121
- HAVE_EE_KEY = bool(EARTH_ENGINE_KEY)
122
- # EE readiness flag we'll set after attempted init
123
- EE_READY = False
124
-
125
- # Attempt to initialize Earth Engine if credentials provided and ee module available
126
- if EE_OK and (SERVICE_ACCOUNT or EARTH_ENGINE_KEY):
127
- try:
128
- # If EARTH_ENGINE_KEY is a JSON string, write to temp file and use service account auth
129
- key_file = None
130
- if EARTH_ENGINE_KEY:
131
- # attempt to parse as json content
132
- try:
133
- parsed = json.loads(EARTH_ENGINE_KEY)
134
- # write to /tmp/geomate_ee_key.json
135
- key_file = "/tmp/geomate_ee_key.json"
136
- with open(key_file, "w") as f:
137
- json.dump(parsed, f)
138
- except Exception:
139
- # maybe EARTH_ENGINE_KEY is a path already on disk (unlikely on HF)
140
- key_file = EARTH_ENGINE_KEY if os.path.exists(EARTH_ENGINE_KEY) else None
141
- if key_file and SERVICE_ACCOUNT:
142
- try:
143
- # Use oauth2client if available
144
- from oauth2client.service_account import ServiceAccountCredentials
145
- creds = ServiceAccountCredentials.from_json_keyfile_name(key_file, scopes=['https://www.googleapis.com/auth/earthengine'])
146
- ee.Initialize(creds)
147
- EE_READY = True
148
- except Exception:
149
- # fallback: try ee.Initialize with service_account
150
- try:
151
- # This call may fail depending on ee versions; keep safe
152
- ee.Initialize()
153
- EE_READY = True
154
- except Exception:
155
- EE_READY = False
156
- else:
157
- # try simple ee.Initialize()
158
- try:
159
- ee.Initialize()
160
- EE_READY = True
161
- except Exception:
162
- EE_READY = False
163
- except Exception:
164
- EE_READY = False
165
- else:
166
- EE_READY = False
167
-
168
- # -------------------------
169
- # Session state initialization
170
- # -------------------------
171
- # site_descriptions: list of dicts (max 4). We'll store as list for site ordering but also index by name.
172
- if "site_descriptions" not in ss:
173
  # default single site
174
- ss["site_descriptions"] = []
175
- if "active_site_index" not in ss:
176
- ss["active_site_index"] = 0
177
- if "llm_model" not in ss:
178
- ss["llm_model"] = "llama3-8b-8192"
179
- if "page" not in ss:
180
- ss["page"] = "Landing"
181
- if "rag_memory" not in ss:
182
- ss["rag_memory"] = {} # per-site chat memory
183
- if "classifier_states" not in ss:
184
- ss["classifier_states"] = {} # per-site classifier step & inputs
185
-
186
- # Utility: create default site structure
187
- def make_empty_site(name: str = "Site 1") -> dict:
188
- return {
189
- "Site Name": name,
190
- "Site Coordinates": "",
191
- "lat": None,
192
- "lon": None,
193
- "Load Bearing Capacity": None,
194
- "Skin Shear Strength": None,
195
- "Relative Compaction": None,
196
- "Rate of Consolidation": None,
197
- "Nature of Construction": None,
198
- "Soil Profile": None,
199
- "Flood Data": None,
200
- "Seismic Data": None,
201
- "GSD": None,
202
- "USCS": None,
203
- "AASHTO": None,
204
- "GI": None,
205
- "classifier_inputs": {},
206
- "classifier_decision_path": "",
207
- "chat_history": [],
208
- "report_convo_state": 0,
209
- "map_snapshot": None,
210
- "classifier_state": 0,
211
- "classifier_chat": []
212
- }
213
-
214
- # ensure at least one site exists
215
- if len(ss["site_descriptions"]) == 0:
216
- ss["site_descriptions"].append(make_empty_site("Home"))
217
-
218
- # -------------------------
219
- # Sidebar: site management & LLM model selection
220
- # -------------------------
221
- from streamlit_option_menu import option_menu
222
-
223
- def sidebar_ui():
224
- st.sidebar.markdown("<div style='text-align:center'><h2 style='color:#FF8C00;margin:6px 0'>GeoMate V2</h2></div>", unsafe_allow_html=True)
225
- st.sidebar.markdown("---")
226
 
227
- # Model selector (persist)
228
- st.sidebar.subheader("LLM Model")
229
- model_options = [ "meta-llama/llama-4-maverick-17b-128e-instruct","openai/gpt-oss-20b","llama3-8b-8192", "gemma-7b-it", "mixtral-8x7b-32768"]
230
- ss["llm_model"] = st.sidebar.selectbox("Select LLM model", model_options, index=model_options.index(ss.get("llm_model","openai/gpt-oss-20b")), key="llm_select")
231
 
232
- st.sidebar.markdown("---")
233
- st.sidebar.subheader("Project Sites (max 4)")
234
- # show sites and allow add/remove, choose active site
235
- colA, colB = st.sidebar.columns([2,1])
236
- with colA:
237
- new_site_name = st.text_input("New site name", value="", key="new_site_name_input")
238
- with colB:
239
- if st.button("➕", key="add_site_btn"):
240
- # add new site up to 4
241
- if len(ss["site_descriptions"]) >= 4:
242
- st.sidebar.warning("Maximum 4 sites allowed.")
243
- else:
244
- name = new_site_name.strip() or f"Site {len(ss['site_descriptions'])+1}"
245
- ss["site_descriptions"].append(make_empty_site(name))
246
- ss["active_site_index"] = len(ss["site_descriptions"]) - 1
247
- safe_rerun()
248
 
249
- # list of site names
250
- site_names = [s["Site Name"] for s in ss["site_descriptions"]]
251
- 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")
252
- ss["active_site_index"] = idx
 
 
253
 
254
- # Remove site
255
- if st.sidebar.button("🗑️ Remove Active Site", key="remove_site_btn"):
256
- if len(ss["site_descriptions"]) <= 1:
257
- st.sidebar.warning("Cannot remove last site.")
258
- else:
259
- ss["site_descriptions"].pop(ss["active_site_index"])
260
- ss["active_site_index"] = max(0, ss["active_site_index"] - 1)
261
- safe_rerun()
262
 
263
- st.sidebar.markdown("---")
264
- st.sidebar.subheader("Active Site JSON")
265
- with st.sidebar.expander("Show site JSON", expanded=False):
266
- st.code(json.dumps(ss["site_descriptions"][ss["active_site_index"]], indent=2), language="json")
267
 
268
- st.sidebar.markdown("---")
269
- # Secrets indicator (not blocking)
270
- st.sidebar.subheader("Service Status")
271
- col1, col2 = st.sidebar.columns(2)
272
- col1.markdown("LLM:")
273
- col2.markdown("✅" if HAVE_GROQ else "⚠️ (no Groq)")
274
- col1.markdown("Earth Engine:")
275
- col2.markdown("✅" if EE_READY else "⚠️ (not initialized)")
276
- st.sidebar.markdown("---")
277
- # Navigation menu
278
- pages = ["Landing", "Soil Recognizer", "Soil Classifier", "GSD Curve", "Locator", "GeoMate Ask", "Reports"]
279
- icons = ["house", "image", "flask", "bar-chart", "geo-alt", "robot", "file-earmark-text"]
280
- choice = option_menu(None, pages, icons=icons, menu_icon="cast", default_index=pages.index(ss.get("page","Landing")), orientation="vertical", styles={
281
- "container": {"padding": "0px"},
282
- "nav-link-selected": {"background-color": "#FF7A00"},
283
- })
284
- if choice and choice != ss.get("page"):
285
- ss["page"] = choice
286
- safe_rerun()
287
 
288
- # -------------------------
289
- # Landing UI
290
- # -------------------------
291
- def landing_ui():
292
- st.markdown(
293
- """
294
- <style>
295
- .hero {
296
- background: linear-gradient(135deg,#0f0f0f 0%, #060606 100%);
297
- border-radius: 14px;
298
- padding: 20px;
299
- border: 1px solid rgba(255,122,0,0.08);
300
- }
301
- .glow-btn {
302
- background: linear-gradient(90deg,#ff7a00,#ff3a3a);
303
- color: white;
304
- padding: 10px 18px;
305
- border-radius: 10px;
306
- font-weight:700;
307
- box-shadow: 0 6px 24px rgba(255,122,0,0.12);
308
- border: none;
309
- }
310
- </style>
311
- """, unsafe_allow_html=True)
312
- st.markdown("<div class='hero'>", unsafe_allow_html=True)
313
- st.markdown("<h1 style='color:#FF8C00;margin:0'>🌍 GeoMate V2</h1>", unsafe_allow_html=True)
314
- st.markdown("<p style='color:#ddd'>AI copilot for geotechnical engineering — soil recognition, classification, locator, RAG, professional reports.</p>", unsafe_allow_html=True)
315
- st.markdown("</div>", unsafe_allow_html=True)
316
- st.markdown("---")
317
- st.write("Quick actions")
318
- c1, c2, c3 = st.columns(3)
319
- if c1.button("🖼️ Soil Recognizer"):
320
- ss["page"] = "Soil Recognizer"; safe_rerun()
321
- if c2.button("🧪 Soil Classifier"):
322
- ss["page"] = "Soil Classifier"; safe_rerun()
323
- if c3.button("📊 GSD Curve"):
324
- ss["page"] = "GSD Curve"; safe_rerun()
325
- c4, c5, c6 = st.columns(3)
326
- if c4.button("🌍 Locator"):
327
- ss["page"] = "Locator"; safe_rerun()
328
- if c5.button("🤖 GeoMate Ask"):
329
- ss["page"] = "GeoMate Ask"; safe_rerun()
330
- if c6.button("📑 Reports"):
331
- ss["page"] = "Reports"; safe_rerun()
332
 
333
- # -------------------------
334
- # Utility: active site helpers
335
- # -------------------------
336
- def active_site() -> Tuple[str, dict]:
337
- idx = ss.get("active_site_index", 0)
338
- idx = max(0, min(idx, len(ss["site_descriptions"]) - 1))
339
- ss["active_site_index"] = idx
340
- site = ss["site_descriptions"][idx]
341
- return idx, site
342
 
343
- def save_site_field(field: str, value: Any):
344
- idx, site = active_site()
345
- site[field] = value
346
- ss["site_descriptions"][idx] = site
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
 
348
- # -------------------------
349
- # USCS & AASHTO verbatim logic (function)
350
- # -------------------------
351
- # Uses exact logic from your script and mapping of descriptor strings to numbers
352
- def uscs_aashto_from_inputs(inputs: Dict[str,Any]) -> Tuple[str,str,str,int,Dict[str,str]]:
353
  """
354
- Return: (result_text, uscs_symbol, aashto_symbol, GI, char_summary)
 
355
  """
356
- # Engineering characteristics dictionary (detailed-ish)
357
- ENGINEERING_CHARACTERISTICS = {
358
- "Gravel": {
359
- "Settlement": "None",
360
- "Quicksand": "Impossible",
361
- "Frost-heaving": "None",
362
- "Groundwater_lowering": "Possible",
363
- "Cement_grouting": "Possible",
364
- "Silicate_bitumen_injections": "Unsuitable",
365
- "Compressed_air": "Possible"
366
- },
367
- "Coarse sand": {"Settlement":"None","Quicksand":"Impossible","Frost-heaving":"None"},
368
- "Medium sand": {"Settlement":"None","Quicksand":"Unlikely"},
369
- "Fine sand": {"Settlement":"None","Quicksand":"Liable"},
370
- "Silt": {"Settlement":"Occurs","Quicksand":"Liable","Frost-heaving":"Occurs"},
371
- "Clay": {"Settlement":"Occurs","Quicksand":"Impossible"}
372
- }
373
-
374
  opt = str(inputs.get("opt","n")).lower()
375
  if opt == 'y':
376
  uscs = "Pt"
377
  uscs_expl = "Peat / organic soil — compressible, high organic content; poor engineering properties for load-bearing without special treatment."
378
  aashto = "Organic (special handling)"
379
- GI = 0
380
  result_text = f"According to USCS, the soil is {uscs} — {uscs_expl}\nAccording to AASHTO, the soil is {aashto}."
381
- return result_text, uscs, aashto, GI, {"summary":"Organic peat: large settlement, low strength."}
382
-
383
- # read numeric inputs safely
384
- P2 = float(inputs.get("P2", 0.0))
385
- P4 = float(inputs.get("P4", 0.0))
386
- D60 = float(inputs.get("D60", 0.0))
387
- D30 = float(inputs.get("D30", 0.0))
388
- D10 = float(inputs.get("D10", 0.0))
389
- LL = float(inputs.get("LL", 0.0))
390
- PL = float(inputs.get("PL", 0.0))
391
  PI = LL - PL
392
 
393
- Cu = (D60 / D10) if (D10 > 0 and D60 > 0) else 0
394
- Cc = ((D30 ** 2) / (D10 * D60)) if (D10 > 0 and D30 > 0 and D60 > 0) else 0
395
 
396
- uscs = "Unknown"; uscs_expl = ""
 
 
397
  if P2 <= 50:
398
- # Coarse-Grained
399
  if P4 <= 50:
400
  # Gravels
401
  if Cu and Cc:
402
  if Cu >= 4 and 1 <= Cc <= 3:
403
- uscs, uscs_expl = "GW", "Well-graded gravel (good engineering properties, high strength, good drainage)."
404
  else:
405
- uscs, uscs_expl = "GP", "Poorly-graded gravel (less favorable gradation)."
406
  else:
407
  if PI < 4 or PI < 0.73 * (LL - 20):
408
- uscs, uscs_expl = "GM", "Silty gravel (fines may reduce permeability and strength)."
409
  elif PI > 7 and PI > 0.73 * (LL - 20):
410
- uscs, uscs_expl = "GC", "Clayey gravel (higher plasticity)."
411
  else:
412
- uscs, uscs_expl = "GM-GC", "Gravel with mixed silt/clay fines."
413
  else:
414
  # Sands
415
  if Cu and Cc:
416
  if Cu >= 6 and 1 <= Cc <= 3:
417
- uscs, uscs_expl = "SW", "Well-graded sand (good compaction and drainage)."
418
  else:
419
- uscs, uscs_expl = "SP", "Poorly-graded sand (uniform or gap-graded)."
420
  else:
421
  if PI < 4 or PI <= 0.73 * (LL - 20):
422
- uscs, uscs_expl = "SM", "Silty sand (low-plasticity fines)."
423
  elif PI > 7 and PI > 0.73 * (LL - 20):
424
- uscs, uscs_expl = "SC", "Clayey sand (clayey fines present)."
425
  else:
426
- uscs, uscs_expl = "SM-SC", "Transition between silty sand and clayey sand."
427
  else:
428
- # Fine-grained soils
429
  nDS = int(inputs.get("nDS", 5))
430
  nDIL = int(inputs.get("nDIL", 6))
431
  nTG = int(inputs.get("nTG", 6))
 
432
  if LL < 50:
433
  if 20 <= LL < 50 and PI <= 0.73 * (LL - 20):
434
  if nDS == 1 or nDIL == 3 or nTG == 3:
435
- uscs, uscs_expl = "ML", "Silt (low plasticity)."
436
  elif nDS == 3 or nDIL == 3 or nTG == 3:
437
- uscs, uscs_expl = "OL", "Organic silt (low plasticity)."
438
  else:
439
- uscs, uscs_expl = "ML-OL", "Mixed silt/organic silt."
440
  elif 10 <= LL <= 30 and 4 <= PI <= 7 and PI > 0.72 * (LL - 20):
441
  if nDS == 1 or nDIL == 1 or nTG == 1:
442
- uscs, uscs_expl = "ML", "Silt."
443
  elif nDS == 2 or nDIL == 2 or nTG == 2:
444
- uscs, uscs_expl = "CL", "Clay (low plasticity)."
445
  else:
446
- uscs, uscs_expl = "ML-CL", "Mixed silt/clay."
447
  else:
448
- uscs, uscs_expl = "CL", "Clay (low plasticity)."
449
  else:
450
  if PI < 0.73 * (LL - 20):
451
  if nDS == 3 or nDIL == 4 or nTG == 4:
452
- uscs, uscs_expl = "MH", "Silt (high plasticity)"
453
  elif nDS == 2 or nDIL == 2 or nTG == 4:
454
- uscs, uscs_expl = "OH", "Organic silt/clay (high plasticity)"
455
  else:
456
- uscs, uscs_expl = "MH-OH", "Mixed high-plasticity silt/organic"
457
  else:
458
- uscs, uscs_expl = "CH", "Clay (high plasticity)"
459
 
460
  # AASHTO logic
461
  if P2 <= 35:
@@ -484,9 +347,12 @@ def uscs_aashto_from_inputs(inputs: Dict[str,Any]) -> Tuple[str,str,str,int,Dict
484
  elif LL <= 40 and PI >= 11:
485
  aashto = "A-6"
486
  else:
487
- aashto = "A-7-5" if PI <= (LL - 30) else "A-7-6"
 
 
 
488
 
489
- # Group Index (GI)
490
  a = P2 - 35
491
  a = 0 if a < 0 else (40 if a > 40 else a)
492
  b = P2 - 15
@@ -497,760 +363,156 @@ def uscs_aashto_from_inputs(inputs: Dict[str,Any]) -> Tuple[str,str,str,int,Dict
497
  d = 0 if d < 0 else (20 if d > 20 else d)
498
  GI = floor(0.2 * a + 0.005 * a * c + 0.01 * b * d)
499
 
500
- aashto_expl = f"{aashto} (GI = {GI})"
501
 
502
- # Choose characteristic summary based on USCS family
503
- char_summary = ENGINEERING_CHARACTERISTICS.get("Silt", {})
504
- if uscs.startswith(("G", "S")):
505
- char_summary = ENGINEERING_CHARACTERISTICS.get("Coarse sand", ENGINEERING_CHARACTERISTICS.get("Gravel"))
506
- if uscs.startswith(("M", "C", "O", "H")):
 
 
507
  char_summary = ENGINEERING_CHARACTERISTICS.get("Silt", {})
508
 
509
- 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"
510
- for k, v in char_summary.items():
511
- result_text += f"- {k}: {v}\n"
512
-
513
  return result_text, uscs, aashto, GI, char_summary
514
 
515
- # -------------------------
516
- # GSD Curve page (separate)
517
- # -------------------------
518
- def gsd_curve_ui():
519
- st.header("📊 Grain Size Distribution (GSD) Curve")
520
- _, site = active_site()
521
- st.markdown(f"**Active site:** {site['Site Name']}")
522
-
523
- 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.")
524
-
525
- col_up, col_manual = st.columns([1,1])
526
- uploaded = None
527
- sieve = None; passing = None
528
- with col_up:
529
- uploaded = st.file_uploader("Upload CSV (diameter_mm, percent_passing)", type=["csv","txt"], key=f"gsd_upload_{site['Site Name']}")
530
- if uploaded:
531
- try:
532
- df = pd.read_csv(uploaded)
533
- # heuristic: first numeric column = diameter, second numeric = percent passing
534
- numeric_cols = [c for c in df.columns if np.issubdtype(df[c].dtype, np.number)]
535
- if len(numeric_cols) >= 2:
536
- sieve = df[numeric_cols[0]].values.astype(float)
537
- passing = df[numeric_cols[1]].values.astype(float)
538
- else:
539
- # try to coerce
540
- sieve = df.iloc[:,0].astype(float).values
541
- passing = df.iloc[:,1].astype(float).values
542
- st.success("CSV loaded.")
543
- except Exception as e:
544
- st.error(f"Error reading CSV: {e}")
545
- sieve = passing = None
546
-
547
- with col_manual:
548
- diam_text = st.text_area("Diameters (mm) comma-separated (e.g. 75,50,37.5,...)", key=f"gsd_diams_{site['Site Name']}")
549
- pass_text = st.text_area("% Passing comma-separated (same order)", key=f"gsd_pass_{site['Site Name']}")
550
- if diam_text.strip() and pass_text.strip():
551
- try:
552
- sieve = np.array([float(x.strip()) for x in diam_text.split(",") if x.strip()])
553
- passing = np.array([float(x.strip()) for x in pass_text.split(",") if x.strip()])
554
- except Exception as e:
555
- st.error(f"Invalid manual input: {e}")
556
- sieve = passing = None
557
-
558
- if sieve is None or passing is None:
559
- st.info("Provide GSD data above to compute D-values.")
560
- return
561
-
562
- # Sort descending by sieve
563
- order = np.argsort(-sieve)
564
- sieve = sieve[order]; passing = passing[order]
565
- # Ensure percent is in 0-100
566
- if np.any(passing < 0) or np.any(passing > 100):
567
- st.warning("Some % passing values are outside 0-100. Please verify.")
568
- # Interpolate percent -> diameter (need percent increasing)
569
- percent = passing.copy()
570
- if not np.all(np.diff(percent) >= 0):
571
- percent = percent[::-1]; sieve = sieve[::-1]
572
- # Ensure arrays are floats
573
- percent = percent.astype(float); sieve = sieve.astype(float)
574
- # interpolation function (percent -> diameter)
575
- def interp_d(pct: float) -> Optional[float]:
576
- try:
577
- # xp must be increasing
578
- return float(np.interp(pct, percent, sieve))
579
- except Exception:
580
- return None
581
- D10 = interp_d(10.0); D30 = interp_d(30.0); D60 = interp_d(60.0)
582
- Cu = (D60 / D10) if (D10 and D60 and D10>0) else None
583
- Cc = (D30**2)/(D10*D60) if (D10 and D30 and D60 and D10>0) else None
584
-
585
- # Plot
586
- fig, ax = plt.subplots(figsize=(7,4))
587
- ax.plot(sieve, passing, marker='o', label="% Passing")
588
- ax.set_xscale('log')
589
- ax.invert_xaxis()
590
- ax.set_xlabel("Particle diameter (mm) — log scale")
591
  ax.set_ylabel("% Passing")
592
- if D10: ax.axvline(D10, color='orange', linestyle='--', label=f"D10={D10:.4g} mm")
593
- if D30: ax.axvline(D30, color='red', linestyle='--', label=f"D30={D30:.4g} mm")
594
- if D60: ax.axvline(D60, color='blue', linestyle='--', label=f"D60={D60:.4g} mm")
595
- ax.grid(True, which='both', linestyle='--', linewidth=0.4)
596
- ax.legend()
597
- st.pyplot(fig)
598
-
599
- # Save to active site
600
- idx, sdict = active_site()
601
- sdict["GSD"] = {
602
- "sieve_mm": sieve.tolist(),
603
- "percent_passing": passing.tolist(),
604
- "D10": float(D10) if D10 is not None else None,
605
- "D30": float(D30) if D30 is not None else None,
606
- "D60": float(D60) if D60 is not None else None,
607
- "Cu": float(Cu) if Cu is not None else None,
608
- "Cc": float(Cc) if Cc is not None else None
609
- }
610
- ss["site_descriptions"][idx] = sdict
611
- st.success(f"Saved GSD to site: D10={D10}, D30={D30}, D60={D60}")
612
- if st.button("Copy D-values to Soil Classifier inputs (for this site)", key=f"copy_d_to_cls_{sdict['Site Name']}"):
613
- ss.setdefault("classifier_states", {})
614
- ss["classifier_states"].setdefault(sdict["Site Name"], {"step":0, "inputs":{}})
615
- ss["classifier_states"][sdict["Site Name"]]["inputs"]["D10"] = float(D10) if D10 is not None else 0.0
616
- ss["classifier_states"][sdict["Site Name"]]["inputs"]["D30"] = float(D30) if D30 is not None else 0.0
617
- ss["classifier_states"][sdict["Site Name"]]["inputs"]["D60"] = float(D60) if D60 is not None else 0.0
618
- st.info("Copied. Go to Soil Classifier page and continue.")
619
- st.markdown("---")
620
- st.caption("Tip: If your classifier asks for D-values and you don't have them, compute them here and copy them back.")
621
-
622
- # -------------------------
623
- # Soil Classifier (chatbot-style; smart input flow)
624
- # -------------------------
625
- def soil_classifier_ui():
626
- st.header("🧪 Soil Classifier — Chatbot-style (smart inputs)")
627
- idx, sdict = active_site()
628
- site_name = sdict["Site Name"]
629
 
630
- # Prepare classifier state container per-site
631
- ss.setdefault("classifier_states", {})
632
- state = ss["classifier_states"].setdefault(site_name, {"step":0, "inputs":{}})
633
 
634
- # Pre-populate inputs from site GSD if present
635
- if sdict.get("GSD"):
636
- g = sdict["GSD"]
637
- state["inputs"].setdefault("D10", g.get("D10", 0.0))
638
- state["inputs"].setdefault("D30", g.get("D30", 0.0))
639
- state["inputs"].setdefault("D60", g.get("D60", 0.0))
640
-
641
- # Dropdown exact strings mapping per your requested table
642
- dil_options = [
643
- "1. Quick to slow",
644
- "2. None to very slow",
645
- "3. Slow",
646
- "4. Slow to none",
647
- "5. None",
648
- "6. Null?"
649
- ]
650
- tough_options = [
651
- "1. None",
652
- "2. Medium",
653
- "3. Slight?",
654
- "4. Slight to Medium?",
655
- "5. High",
656
- "6. Null?"
657
- ]
658
- dry_options = [
659
- "1. None to slight",
660
- "2. Medium to high",
661
- "3. Slight to Medium",
662
- "4. High to very high",
663
- "5. Null?"
664
- ]
665
-
666
- # Mode selector: Both / USCS only / AASHTO only
667
- if "classifier_mode" not in ss:
668
- ss["classifier_mode"] = {}
669
- ss["classifier_mode"].setdefault(site_name, "Both")
670
- 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}")
671
- ss["classifier_mode"][site_name] = mode
672
-
673
- # Step machine:
674
- step = state.get("step", 0)
675
- inputs = state.setdefault("inputs", {})
676
-
677
- def goto(n):
678
- state["step"] = n
679
- ss["classifier_states"][site_name] = state
680
- safe_rerun()
681
-
682
- def save_input(key, value):
683
- inputs[key] = value
684
- state["inputs"] = inputs
685
- ss["classifier_states"][site_name] = state
686
-
687
- # Chat style UI: show recent conversation history as simple list
688
- st.markdown(f"**Active site:** {site_name}")
689
- st.markdown("---")
690
-
691
- if step == 0:
692
- st.markdown("**GeoMate:** Hello! I'm the Soil Classifier bot. Shall we begin classification for this site?")
693
- c1, c2 = st.columns(2)
694
- if c1.button("Yes — Start", key=f"cls_start_{site_name}"):
695
- goto(1)
696
- if c2.button("Cancel", key=f"cls_cancel_{site_name}"):
697
- st.info("Classifier cancelled.")
698
-
699
- elif step == 1:
700
- st.markdown("**GeoMate:** Is the soil organic (contains high organic matter, spongy, odour)?")
701
- c1, c2 = st.columns(2)
702
- if c1.button("No (inorganic)", key=f"org_no_{site_name}"):
703
- save_input("opt", "n"); goto(2)
704
- if c2.button("Yes (organic)", key=f"org_yes_{site_name}"):
705
- save_input("opt", "y"); goto(12) # skip to final classification for organic
706
-
707
- elif step == 2:
708
- st.markdown("**GeoMate:** What is the percentage passing the #200 sieve (0.075 mm)?")
709
- 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")
710
- c1, c2, c3 = st.columns([1,1,1])
711
- if c1.button("Next", key=f"P2_next_{site_name}"):
712
- save_input("P2", float(val)); goto(3)
713
- if c2.button("Skip", key=f"P2_skip_{site_name}"):
714
- save_input("P2", 0.0); goto(3)
715
- if c3.button("Back", key=f"P2_back_{site_name}"):
716
- goto(0)
717
-
718
- elif step == 3:
719
- # decide path based on P2
720
- P2 = float(inputs.get("P2", 0.0))
721
- if P2 > 50:
722
- st.markdown("**GeoMate:** P2 > 50 — fine-grained soil path selected.")
723
- if st.button("Continue (fine-grained)", key=f"cont_fine_{site_name}"):
724
- goto(4)
725
- else:
726
- st.markdown("**GeoMate:** P2 <= 50 — coarse-grained soil path selected.")
727
- if st.button("Continue (coarse-grained)", key=f"cont_coarse_{site_name}"):
728
- goto(6)
729
-
730
- # Fine-grained branch
731
- elif step == 4:
732
- st.markdown("**GeoMate:** Enter Liquid Limit (LL).")
733
- 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")
734
- col1, col2 = st.columns(2)
735
- if col1.button("Next", key=f"LL_next_{site_name}"):
736
- save_input("LL", float(val)); goto(5)
737
- if col2.button("Back", key=f"LL_back_{site_name}"):
738
- goto(3)
739
-
740
- elif step == 5:
741
- st.markdown("**GeoMate:** Enter Plastic Limit (PL).")
742
- 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")
743
- col1, col2 = st.columns(2)
744
- if col1.button("Next", key=f"PL_next_{site_name}"):
745
- save_input("PL", float(val)); goto(11) # go to descriptors step
746
- if col2.button("Back", key=f"PL_back_{site_name}"):
747
- goto(4)
748
-
749
- # Coarse branch: ask % passing #4 and D-values option
750
- elif step == 6:
751
- st.markdown("**GeoMate:** What is the % passing sieve no. 4 (4.75 mm)?")
752
- 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")
753
- c1, c2, c3 = st.columns([1,1,1])
754
- if c1.button("Next", key=f"P4_next_{site_name}"):
755
- save_input("P4", float(val)); goto(7)
756
- if c2.button("Compute D-values (GSD page)", key=f"P4_gsd_{site_name}"):
757
- st.info("Please use GSD Curve page to compute D-values and then copy them back to classifier.")
758
- if c3.button("Back", key=f"P4_back_{site_name}"):
759
- goto(3)
760
-
761
- elif step == 7:
762
- st.markdown("**GeoMate:** Do you know D60, D30, D10 diameters (mm)?")
763
- c1, c2, c3 = st.columns([1,1,1])
764
- if c1.button("Yes — enter values", key=f"dvals_yes_{site_name}"):
765
- goto(8)
766
- if c2.button("No — compute from GSD", key=f"dvals_no_{site_name}"):
767
- st.info("Use the GSD Curve page and then click 'Copy D-values to Soil Classifier' there.")
768
- if c3.button("Skip", key=f"dvals_skip_{site_name}"):
769
- save_input("D60", 0.0); save_input("D30", 0.0); save_input("D10", 0.0); goto(11)
770
-
771
- elif step == 8:
772
- st.markdown("**GeoMate:** Enter D60 (mm)")
773
- val = st.number_input("D60 (mm)", value=float(inputs.get("D60",0.0)), min_value=0.0, key=f"D60_{site_name}")
774
- if st.button("Next", key=f"D60_next_{site_name}"):
775
- save_input("D60", float(val)); goto(9)
776
- if st.button("Back", key=f"D60_back_{site_name}"):
777
- goto(7)
778
-
779
- elif step == 9:
780
- st.markdown("**GeoMate:** Enter D30 (mm)")
781
- val = st.number_input("D30 (mm)", value=float(inputs.get("D30",0.0)), min_value=0.0, key=f"D30_{site_name}")
782
- if st.button("Next", key=f"D30_next_{site_name}"):
783
- save_input("D30", float(val)); goto(10)
784
- if st.button("Back", key=f"D30_back_{site_name}"):
785
- goto(8)
786
-
787
- elif step == 10:
788
- st.markdown("**GeoMate:** Enter D10 (mm)")
789
- val = st.number_input("D10 (mm)", value=float(inputs.get("D10",0.0)), min_value=0.0, key=f"D10_{site_name}")
790
- if st.button("Next", key=f"D10_next_{site_name}"):
791
- save_input("D10", float(val)); goto(11)
792
- if st.button("Back", key=f"D10_back_{site_name}"):
793
- goto(9)
794
-
795
- # descriptors (for fine soils) and finishing
796
- elif step == 11:
797
- st.markdown("**GeoMate:** Fine soil descriptors (if applicable). You can skip.")
798
- sel_dry = st.selectbox("Dry strength", dry_options, index=min(2, len(dry_options)-1), key=f"dry_{site_name}")
799
- sel_dil = st.selectbox("Dilatancy", dil_options, index=0, key=f"dil_{site_name}")
800
- sel_tg = st.selectbox("Toughness", tough_options, index=0, key=f"tough_{site_name}")
801
- col1, col2, col3 = st.columns([1,1,1])
802
- # map to numeric - map strings to numbers i+1
803
- dry_map = {dry_options[i]: i+1 for i in range(len(dry_options))}
804
- dil_map = {dil_options[i]: i+1 for i in range(len(dil_options))}
805
- tough_map = {tough_options[i]: i+1 for i in range(len(tough_options))}
806
- if col1.button("Save & Continue", key=f"desc_save_{site_name}"):
807
- save_input("nDS", dry_map.get(sel_dry, 5))
808
- save_input("nDIL", dil_map.get(sel_dil, 6))
809
- save_input("nTG", tough_map.get(sel_tg, 6))
810
- goto(12)
811
- if col2.button("Skip descriptors", key=f"desc_skip_{site_name}"):
812
- save_input("nDS", 5); save_input("nDIL", 6); save_input("nTG", 6); goto(12)
813
- if col3.button("Back", key=f"desc_back_{site_name}"):
814
- # go to previous appropriate step
815
- # if LL in inputs, go to LL/PL steps else go to coarse branch
816
- if inputs.get("LL") is not None and inputs.get("PL") is not None:
817
- goto(5)
818
- else:
819
- goto(6)
820
-
821
- elif step == 12:
822
- st.markdown("**GeoMate:** Ready to classify. Review inputs and press **Classify**.")
823
- st.json(inputs)
824
- col1, col2 = st.columns([1,1])
825
- if col1.button("Classify", key=f"classify_now_{site_name}"):
826
- try:
827
- res_text, uscs_sym, aashto_sym, GI, char_summary = uscs_aashto_from_inputs(inputs)
828
- # Save to site dict
829
- sdict["USCS"] = uscs_sym
830
- sdict["AASHTO"] = aashto_sym
831
- sdict["GI"] = GI
832
- sdict["classifier_inputs"] = inputs
833
- sdict["classifier_decision_path"] = res_text
834
- # persist
835
- ss["site_descriptions"][ss["active_site_index"]] = sdict
836
- st.success("Classification complete and saved.")
837
- st.markdown("### Result")
838
- if mode == "USCS only":
839
- st.markdown(f"**USCS:** {uscs_sym}")
840
- elif mode == "AASHTO only":
841
- st.markdown(f"**AASHTO:** {aashto_sym} (GI={GI})")
842
- else:
843
- st.markdown(res_text)
844
- # offer PDF export
845
- if FPDF_OK and st.button("Export Classification PDF", key=f"exp_cls_pdf_{site_name}"):
846
- pdf = FPDF()
847
- pdf.add_page()
848
- pdf.set_font("Arial", "B", 14)
849
- pdf.cell(0, 8, f"GeoMate Classification — {site_name}", ln=1)
850
- pdf.ln(4)
851
- pdf.set_font("Arial", "", 11)
852
- pdf.multi_cell(0, 7, res_text)
853
- pdf.ln(4)
854
- pdf.set_font("Arial", "B", 12)
855
- pdf.cell(0, 6, "Inputs:", ln=1)
856
- pdf.set_font("Arial", "", 10)
857
- for k, v in inputs.items():
858
- pdf.cell(0,5, f"{k}: {v}", ln=1)
859
- fn = f"{site_name.replace(' ','_')}_classification.pdf"
860
- pdf.output(fn)
861
- with open(fn,"rb") as f:
862
- st.download_button("Download PDF", f, file_name=fn, mime="application/pdf")
863
- # done
864
- except Exception as e:
865
- st.error(f"Classification failed: {e}\n{traceback.format_exc()}")
866
- if col2.button("Back to edit", key=f"back_edit_{site_name}"):
867
- goto(11)
868
-
869
- else:
870
- st.warning("Classifier state reset.")
871
- state["step"] = 0
872
- ss["classifier_states"][site_name] = state
873
-
874
- # -------------------------
875
- # Locator page (chat-style)
876
- # -------------------------
877
- def locator_ui():
878
- st.header("🌍 Locator — define AOI (GeoJSON or coordinates)")
879
- idx, sdict = active_site()
880
- site_name = sdict["Site Name"]
881
- st.markdown(f"**Active site:** {site_name}")
882
- 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.")
883
-
884
- # Chat-like: ask for GeoJSON or coordinates
885
- choice = st.radio("Provide:", ["GeoJSON (polygon)", "Coordinates (lat, lon)"], index=0, key=f"loc_choice_{site_name}")
886
- if choice.startswith("GeoJSON"):
887
- geo_text = st.text_area("Paste GeoJSON (Polygon or MultiPolygon)", value=sdict.get("map_snapshot") or "", key=f"geojson_area_{site_name}", height=180)
888
- if st.button("Save AOI and show map", key=f"save_aoi_{site_name}"):
889
- if not geo_text.strip():
890
- st.error("No GeoJSON provided.")
891
- else:
892
- try:
893
- gj = json.loads(geo_text)
894
- sdict["map_snapshot"] = gj
895
- ss["site_descriptions"][idx] = sdict
896
- st.success("AOI saved.")
897
- # Try to display on simple map: compute centroid and show with st.map
898
- # Find centroid of polygon(s)
899
- def centroid_of_geojson(gj_obj):
900
- coords = []
901
- if gj_obj.get("type") == "FeatureCollection":
902
- for f in gj_obj.get("features", []):
903
- geom = f.get("geometry", {})
904
- coords.extend(_extract_coords_from_geom(geom))
905
- else:
906
- geom = gj_obj if "geometry" not in gj_obj else gj_obj["geometry"]
907
- coords.extend(_extract_coords_from_geom(geom))
908
- if not coords:
909
- return None
910
- arr = np.array(coords)
911
- return float(arr[:,1].mean()), float(arr[:,0].mean())
912
- def _extract_coords_from_geom(geom):
913
- if not geom:
914
- return []
915
- t = geom.get("type","")
916
- if t == "Polygon":
917
- return [tuple(pt) for pt in geom.get("coordinates", [])[0]]
918
- if t == "MultiPolygon":
919
- pts=[]
920
- for poly in geom.get("coordinates", []):
921
- pts.extend([tuple(pt) for pt in poly[0]])
922
- return pts
923
- if t == "Point":
924
- return [tuple(geom.get("coordinates",[]))]
925
- return []
926
- cent = centroid_of_geojson(gj)
927
- if cent:
928
- latc, lonc = cent
929
- sdict["lat"] = latc; sdict["lon"] = lonc
930
- ss["site_descriptions"][idx] = sdict
931
- st.map(pd.DataFrame({"lat":[latc],"lon":[lonc]}))
932
- else:
933
- st.info("Could not compute centroid for map preview, but AOI saved.")
934
- except Exception as e:
935
- st.error(f"Invalid GeoJSON: {e}")
936
- else:
937
- # Coordinates mode
938
- lat = st.number_input("Latitude", value=float(sdict.get("lat") or 0.0), key=f"loc_lat_{site_name}")
939
- lon = st.number_input("Longitude", value=float(sdict.get("lon") or 0.0), key=f"loc_lon_{site_name}")
940
- if st.button("Save coordinates and show map", key=f"save_coords_{site_name}"):
941
- sdict["lat"] = float(lat); sdict["lon"] = float(lon)
942
- ss["site_descriptions"][idx] = sdict
943
- st.success("Coordinates saved.")
944
- st.map(pd.DataFrame({"lat":[lat],"lon":[lon]}))
945
-
946
- # If EE is available, offer to fetch raster/time series (placeholder)
947
- st.markdown("---")
948
- if EE_READY:
949
- if st.button("Fetch Earth Engine data (soil profile / climate / flood / seismic) — experimental"):
950
- st.info("Earth Engine available — fetching (placeholder).")
951
- # Placeholder: real implementation would call ee.Dataset/time series and store results
952
- try:
953
- # example: add a placeholder soil profile
954
- sdict["Soil Profile"] = "Placeholder Earth Engine soil profile data (EE initialized)."
955
- sdict["Flood Data"] = "Placeholder flood history (20 years) from EE."
956
- sdict["Seismic Data"] = "Placeholder seismic history (20 years) from EE."
957
- ss["site_descriptions"][idx] = sdict
958
- st.success("Earth Engine data fetched and saved to site (placeholder).")
959
- except Exception as e:
960
- st.error(f"EE fetch failed: {e}")
961
- else:
962
- 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.")
963
-
964
- # -------------------------
965
- # GeoMate Ask (RAG chatbot) — simplified RAG integration
966
- # -------------------------
967
- def run_llm_completion(prompt: str, model: str = "llama3-8b-8192") -> str:
968
- """Minimal LLM wrapper: uses Groq if available; else returns a dummy but structured answer."""
969
- if GROQ_OK and GROQ_KEY:
970
  try:
971
- client = Groq(api_key=GROQ_KEY)
972
- comp = client.chat.completions.create(model=model, messages=[{"role":"user", "content": prompt}], temperature=0.2, max_tokens=800)
973
- return comp.choices[0].message.content
 
 
 
 
974
  except Exception as e:
975
- return f"(Groq error) {e}"
976
- else:
977
- # Dummy local response for demonstration
978
- return f"(Dummy LLM) I received your prompt and would reply here. Model: {model}\n\nPrompt excerpt:\n{prompt[:400]}"
979
-
980
- def rag_ui():
981
- st.header("🤖 GeoMate Ask — RAG Chatbot (per-site memory)")
982
- idx, sdict = active_site()
983
- site_name = sdict["Site Name"]
984
- st.markdown(f"**Active site:** {site_name}")
985
- # Prepare chat history per site
986
- ss.setdefault("rag_memory", {})
987
- chat = ss["rag_memory"].setdefault(site_name, [])
988
- # Show chat
989
- for turn in chat[-40:]:
990
- role, text = turn.get("role"), turn.get("text")
991
- if role == "user":
992
- st.markdown(f"**You:** {text}")
993
- else:
994
- st.markdown(f"**GeoMate:** {text}")
995
-
996
- # user input
997
- user_prompt = st.text_input("Ask GeoMate (technical)", key=f"rag_input_{site_name}")
998
- col1, col2 = st.columns([3,1])
999
- if col1.button("Send", key=f"rag_send_{site_name}") and user_prompt.strip():
1000
- # append user
1001
- chat.append({"role":"user", "text":user_prompt, "ts": datetime.now().isoformat()})
1002
- ss["rag_memory"][site_name] = chat
1003
- # Build RAG prompt using stored site data + user prompt
1004
- context = {"site": sdict}
1005
- full_prompt = f"Site data (json):\n{json.dumps(context, indent=2)}\n\nUser question:\n{user_prompt}\nPlease answer technically."
1006
- # Run LLM
1007
- with st.spinner("Running LLM..."):
1008
- resp = run_llm_completion(full_prompt, model=ss.get("llm_model"))
1009
- # Append bot response
1010
- chat.append({"role":"assistant", "text":resp, "ts": datetime.now().isoformat()})
1011
- ss["rag_memory"][site_name] = chat
1012
- # Intelligent extraction: try to pick up numeric engineering fields (simple heuristics)
1013
- update_site_description_from_chat(resp, site_name)
1014
- safe_rerun()
1015
-
1016
- # small NLP-ish extractor placeholder
1017
- def update_site_description_from_chat(text: str, site_name: str):
1018
- """Naive extraction: looks for keywords like 'bearing' and a numeric value followed by units."""
1019
- idx = None
1020
- for i, s in enumerate(ss["site_descriptions"]):
1021
- if s["Site Name"] == site_name:
1022
- idx = i; break
1023
- if idx is None:
1024
- return
1025
- # naive patterns
1026
- lowered = text.lower()
1027
- site = ss["site_descriptions"][idx]
1028
- # look for 'bearing' followed by number (psf, kpa)
1029
- import re
1030
- m = re.search(r"bearing.*?([0-9]{2,6})\s*(psf|kpa|kpa\.)?", lowered)
1031
- if m:
1032
- val = m.group(1)
1033
- site["Load Bearing Capacity"] = m.group(0)
1034
- ss["site_descriptions"][idx] = site
1035
-
1036
- # -------------------------
1037
- # Reports page (two types)
1038
- # -------------------------
1039
- def build_classification_pdf_bytes(site_dict: dict) -> bytes:
1040
- """Return bytes of a classification-only PDF for a single site. Try ReportLab then FPDF fallback."""
1041
- text = site_dict.get("classifier_decision_path") or "No classification decision path available."
1042
- inputs = site_dict.get("classifier_inputs", {})
1043
- title = f"GeoMate Classification Report — {site_dict['Site Name']}"
1044
- # Try ReportLab
1045
- if REPORTLAB_OK:
1046
- buf = io.BytesIO()
1047
- doc = SimpleDocTemplate(buf, pagesize=A4)
1048
- styles = getSampleStyleSheet()
1049
- elems = []
1050
- elems.append(Paragraph(title, styles["Title"]))
1051
- elems.append(Spacer(1,6))
1052
- elems.append(Paragraph("Classification result and explanation:", styles["Heading2"]))
1053
- elems.append(Paragraph(text.replace("\n","<br/>"), styles["BodyText"]))
1054
- elems.append(Spacer(1,6))
1055
- elems.append(Paragraph("Inputs:", styles["Heading3"]))
1056
- for k,v in inputs.items():
1057
- elems.append(Paragraph(f"{k}: {v}", styles["BodyText"]))
1058
- doc.build(elems)
1059
- pdf_bytes = buf.getvalue(); buf.close()
1060
- return pdf_bytes
1061
- elif FPDF_OK:
1062
- pdf = FPDF()
1063
- pdf.add_page()
1064
- pdf.set_font("Arial", "B", 14)
1065
- pdf.cell(0, 10, title, ln=1)
1066
- pdf.set_font("Arial", "", 11)
1067
- pdf.multi_cell(0, 6, text)
1068
- pdf.ln(4)
1069
- pdf.set_font("Arial", "B", 12)
1070
- pdf.cell(0,6,"Inputs:", ln=1)
1071
- pdf.set_font("Arial", "", 10)
1072
- for k,v in inputs.items():
1073
- pdf.cell(0,5,f"{k}: {v}", ln=1)
1074
- out = pdf.output(dest="S").encode("latin-1")
1075
- return out
1076
- else:
1077
- # fallback: simple text file disguised as pdf (not ideal)
1078
- return ("Classification:\n"+text+"\n\nInputs:\n"+json.dumps(inputs, indent=2)).encode("utf-8")
1079
-
1080
- def build_full_phase1_pdf_bytes(site_list: List[dict], ext_refs: List[str]) -> bytes:
1081
  """
1082
- Build full geotechnical report (Phase 1) combining site data, classifier results, GSD and maps (if any).
1083
- This is a streamlined generation: for production you would expand each analysis section.
1084
  """
1085
- title = "GeoMate — Full Geotechnical Investigation Report"
1086
- # Use ReportLab if available
1087
- if REPORTLAB_OK:
1088
- buf = io.BytesIO()
1089
- doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=20*mm, rightMargin=20*mm, topMargin=20*mm)
1090
- styles = getSampleStyleSheet()
1091
- elems = []
1092
- elems.append(Paragraph(title, styles["Title"]))
1093
- elems.append(Spacer(1,8))
1094
- elems.append(Paragraph(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}", styles["Normal"]))
1095
- elems.append(Spacer(1,12))
1096
- # Summary
1097
- elems.append(Paragraph("SUMMARY", styles["Heading2"]))
1098
- elems.append(Paragraph("This full geotechnical report was generated by GeoMate V2. The following pages contain site descriptions, classification results and recommendations.", styles["BodyText"]))
1099
- elems.append(PageBreak())
1100
- # For each site:
1101
- for s in site_list:
1102
- elems.append(Paragraph(f"SITE: {s.get('Site Name','Unnamed')}", styles["Heading1"]))
1103
- elems.append(Paragraph(f"Location: {s.get('Site Coordinates','Not provided')}", styles["BodyText"]))
1104
- elems.append(Spacer(1,6))
1105
- # classifier results
1106
- elems.append(Paragraph("Classification", styles["Heading2"]))
1107
- elems.append(Paragraph(s.get("classifier_decision_path","No classification available.").replace("\n","<br/>"), styles["BodyText"]))
1108
- elems.append(Spacer(1,6))
1109
- # GSD table
1110
- if s.get("GSD"):
1111
- elems.append(Paragraph("GSD Summary", styles["Heading2"]))
1112
- g = s["GSD"]
1113
- tdata = [["D10 (mm)", "D30 (mm)", "D60 (mm)", "Cu", "Cc"]]
1114
- tdata.append([str(g.get("D10")), str(g.get("D30")), str(g.get("D60")), str(g.get("Cu")), str(g.get("Cc"))])
1115
- t = Table(tdata, colWidths=[30*mm]*5)
1116
- 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)]))
1117
- elems.append(t)
1118
- elems.append(PageBreak())
1119
- # Add external references
1120
- elems.append(Paragraph("External References", styles["Heading2"]))
1121
- for r in ext_refs:
1122
- elems.append(Paragraph(r, styles["BodyText"]))
1123
- doc.build(elems)
1124
- pdf_bytes = buf.getvalue(); buf.close()
1125
- return pdf_bytes
1126
- elif FPDF_OK:
1127
- pdf = FPDF()
1128
- pdf.add_page()
1129
- pdf.set_font("Arial", "B", 16)
1130
- pdf.cell(0, 10, title, ln=1)
1131
- pdf.set_font("Arial", "", 11)
1132
- pdf.ln(4)
1133
- for s in site_list:
1134
- pdf.set_font("Arial", "B", 13)
1135
- pdf.cell(0, 8, f"Site: {s.get('Site Name','')}", ln=1)
1136
- pdf.set_font("Arial", "", 11)
1137
- pdf.multi_cell(0, 6, s.get("classifier_decision_path","No classification data."))
1138
- pdf.ln(4)
1139
- if s.get("GSD"):
1140
- g = s["GSD"]
1141
- pdf.cell(0, 6, f"GSD D10={g.get('D10')}, D30={g.get('D30')}, D60={g.get('D60')}", ln=1)
1142
- pdf.add_page()
1143
- out = pdf.output(dest="S").encode("latin-1")
1144
- return out
1145
- else:
1146
- # fallback text
1147
- parts = [title, "Generated: "+datetime.now().isoformat()]
1148
- for s in site_list:
1149
- parts.append("SITE: "+s.get("Site Name",""))
1150
- parts.append("Classification:\n"+(s.get("classifier_decision_path") or "No data"))
1151
- parts.append("GSD: "+(json.dumps(s.get("GSD") or {})))
1152
- parts.append("REFERENCES: "+json.dumps(ext_refs))
1153
- return ("\n\n".join(parts)).encode("utf-8")
1154
-
1155
- # Reports UI
1156
- def reports_ui():
1157
- st.header("📑 Reports — Classification-only & Full Geotechnical Report")
1158
- idx, sdict = active_site()
1159
- st.subheader("Classification-only Report")
1160
- st.markdown("Generates a PDF containing the classification result and the decision path for the selected site.")
1161
- if st.button("Generate Classification PDF", key=f"gen_cls_pdf_{sdict['Site Name']}"):
1162
- pdf_bytes = build_classification_pdf_bytes(sdict)
1163
- st.download_button("Download Classification PDF", data=pdf_bytes, file_name=f"{sdict['Site Name']}_classification.pdf", mime="application/pdf")
1164
- st.markdown("---")
1165
- st.subheader("Full Geotechnical Report")
1166
- st.markdown("Chatbot will gather missing parameters and produce a full Phase 1 report for selected sites (up to 4).")
1167
- # multi-select sites
1168
- all_site_names = [s["Site Name"] for s in ss["site_descriptions"]]
1169
- chosen = st.multiselect("Select sites to include", options=all_site_names, default=all_site_names, key="report_sites_select")
1170
- ext_refs_text = st.text_area("External references (one per line)", key="ext_refs")
1171
- # If user wants to have the bot gather missing params, start convo
1172
- if st.button("Start conversational data gather for Full Report", key="start_report_convo"):
1173
- # We'll run a simple in-app conversational loop: for brevity, ask a set of required params per site
1174
- required_questions = [
1175
- ("Load Bearing Capacity", "What is the soil bearing capacity (e.g., 2000 psf or 'Don't know')?"),
1176
- ("Skin Shear Strength", "Provide skin shear strength (kPa) if known, else 'Don't know'"),
1177
- ("Relative Compaction", "Relative compaction (%)"),
1178
- ("Rate of Consolidation", "Rate of consolidation (e.g., cv in m2/year)"),
1179
- ("Nature of Construction", "Nature of construction (e.g., 2-storey residence)"),
1180
- ("Other", "Any other relevant notes or 'None'")
1181
- ]
1182
- # For each chosen site, ask interactively. We'll perform a sequential loop inside the app (chatbot-style rudimentary)
1183
- for site_name in chosen:
1184
- st.info(f"Collecting data for site: {site_name}")
1185
- # Retrieve site dict
1186
- site_idx = [i for i,s in enumerate(ss["site_descriptions"]) if s["Site Name"]==site_name][0]
1187
- site_obj = ss["site_descriptions"][site_idx]
1188
- for field, q in required_questions:
1189
- # If field exists and not None, skip confirmation ask
1190
- current_val = site_obj.get(field)
1191
- if current_val:
1192
- st.write(f"{field} (existing): {current_val}")
1193
- keep = st.radio(f"Keep existing {field} for {site_name}?", ["Keep","Replace"], key=f"keep_{site_name}_{field}")
1194
- if keep == "Keep":
1195
- continue
1196
- ans = st.text_input(q, key=f"report_q_{site_name}_{field}")
1197
- if ans.strip().lower() in ["don't know","dont know","skip","n","no","unknown",""]:
1198
- site_obj[field] = None
1199
- else:
1200
- site_obj[field] = ans.strip()
1201
- ss["site_descriptions"][site_idx] = site_obj
1202
- st.success(f"Data saved for {site_name}.")
1203
- st.info("Conversational gather complete. Use Generate Full Report PDF button to create the PDF.")
1204
-
1205
- if st.button("Generate Full Report PDF", key="gen_full_pdf_btn"):
1206
- # collect selected site dicts
1207
- site_objs = [s for s in ss["site_descriptions"] if s["Site Name"] in chosen]
1208
- ext_refs = [r.strip() for r in ext_refs_text.splitlines() if r.strip()]
1209
- pdf_bytes = build_full_phase1_pdf_bytes(site_objs, ext_refs)
1210
- 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")
1211
- # -------------------------
1212
- # Soil Recognizer (placeholder)
1213
- # -------------------------
1214
- def soil_recognizer_ui():
1215
- st.header("🖼️ Soil Recognizer (image-based)")
1216
- 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.")
1217
- uploaded = st.file_uploader("Upload soil image (jpg/png)", type=["jpg","jpeg","png"], key="sr_upload")
1218
- if uploaded:
1219
- st.image(uploaded, caption="Uploaded image", use_column_width=True)
1220
- # Placeholder inference
1221
- st.warning("Model inference not configured in this Space. To enable, upload 'soil_best_model.pth' and implement model loading code here.")
1222
- if OCR_TESSERACT:
1223
- st.info("Attempting OCR of image (to extract printed text)")
1224
- try:
1225
- img = Image.open(uploaded)
1226
- text = pytesseract.image_to_string(img)
1227
- st.text_area("Extracted text (OCR)", value=text, height=200)
1228
- except Exception as e:
1229
- st.error(f"OCR failed: {e}")
1230
-
1231
- # -------------------------
1232
- # Main UI runner
1233
- # -------------------------
1234
- def main():
1235
- sidebar_ui()
1236
- # Top-level content area routing
1237
- page = ss.get("page", "Landing")
1238
- if page == "Landing":
1239
- landing_ui()
1240
- elif page == "Soil Recognizer":
1241
- soil_recognizer_ui()
1242
- elif page == "Soil Classifier":
1243
- soil_classifier_ui()
1244
- elif page == "GSD Curve":
1245
- gsd_curve_ui()
1246
- elif page == "Locator":
1247
- locator_ui()
1248
- elif page == "GeoMate Ask":
1249
- rag_ui()
1250
- elif page == "Reports":
1251
- reports_ui()
1252
- else:
1253
- st.write("Page not found.")
1254
-
1255
- if __name__ == "__main__":
1256
- main()
 
1
+ # app.py — GeoMate V2 (single-file)
2
+ # Author: generated for user
3
+ # Notes:
4
+ # - Requires Streamlit. See requirements.txt below.
5
+ # - Secrets expected in environment or HF Secrets: GROQ_API_KEY, SERVICE_ACCOUNT
6
+ # - Earth Engine JSON file expected as file-like content or environment variable name EARTH_ENGINE_KEY
7
+ # - This file uses placeholder behavior for heavy integrations (Groq, Earth Engine, FAISS). See comments.
8
 
 
9
  import os
10
  import io
11
+ import re
12
  import json
13
  import math
14
+ import base64
15
+ import tempfile
16
  from datetime import datetime
17
+ from typing import Any, Dict, Tuple, List, Optional
18
 
 
19
  import streamlit as st
20
 
21
+ # Basic data science and plotting
 
 
 
22
  import numpy as np
23
  import pandas as pd
24
  import matplotlib.pyplot as plt
 
25
 
26
+ # PDF generation (ReportLab preferred)
27
+ from reportlab.lib import colors
28
+ from reportlab.lib.pagesizes import A4, landscape
29
+ from reportlab.lib.units import mm
30
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image as RLImage, PageBreak
31
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
32
+
33
+ # Lightweight PDF export for quick classification report
34
+ from fpdf import FPDF
35
+
36
+ # Optional dependencies (guarded)
37
  try:
38
+ import pytesseract
39
+ HAVE_OCR = True
40
  except Exception:
41
+ HAVE_OCR = False
42
 
43
  try:
44
+ import geemap # for Earth Engine mapping in Streamlit
45
+ HAVE_GEEMAP = True
 
 
 
 
 
46
  except Exception:
47
+ HAVE_GEEMAP = False
48
 
 
49
  try:
50
+ import ee
51
+ HAVE_EE = True
52
  except Exception:
53
+ HAVE_EE = False
54
 
55
+ # Groq client (placeholder)
56
  try:
57
  from groq import Groq
58
+ HAVE_GROQ = True
59
  except Exception:
60
+ HAVE_GROQ = False
61
 
62
+ # FAISS and sentence-transformers for embedding search (placeholder)
63
  try:
64
+ import faiss
65
+ from sentence_transformers import SentenceTransformer
66
+ HAVE_FAISS = True
67
  except Exception:
68
+ HAVE_FAISS = False
 
 
69
 
70
+ # ---------------------------
71
+ # App-level configuration
72
+ # ---------------------------
73
+ st.set_page_config(page_title="GeoMate V2", page_icon="🌍", layout="wide")
 
 
 
 
74
 
75
+ # Session alias
76
  ss = st.session_state
77
 
78
+ # ---------------------------
79
+ # Secrets check (required)
80
+ # ---------------------------
81
+ REQUIRED_SECRETS = ["GROQ_API_KEY", "SERVICE_ACCOUNT"]
82
+ missing = [k for k in REQUIRED_SECRETS if not (os.environ.get(k) or (st.secrets and st.secrets.get(k)))]
83
+ # EARTH_ENGINE_KEY expected as a file path or environment variable containing JSON; handle in locator page.
84
+
85
+ if missing:
86
+ st.title("GeoMate V2 — Missing required secrets")
87
+ st.error(f"Missing required environment secrets: {', '.join(missing)}.\n"
88
+ "Please add them to your Hugging Face Space secrets (Settings → Secrets) or environment.\n"
89
+ "Required: GROQ_API_KEY, SERVICE_ACCOUNT. EARTH_ENGINE_KEY (JSON) required for Earth Engine features.")
90
+ st.stop()
91
+
92
+ # Grab Groq key (if present)
93
+ GROQ_API_KEY = os.environ.get("GROQ_API_KEY") or (st.secrets.get("GROQ_API_KEY") if st.secrets else None)
94
+ SERVICE_ACCOUNT = os.environ.get("SERVICE_ACCOUNT") or (st.secrets.get("SERVICE_ACCOUNT") if st.secrets else None)
95
+ EARTH_ENGINE_KEY = os.environ.get("EARTH_ENGINE_KEY") or (st.secrets.get("EARTH_ENGINE_KEY") if st.secrets else None)
96
+
97
+ # ---------------------------
98
+ # Initialize session-state
99
+ # ---------------------------
100
+ if "sites" not in ss:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  # default single site
102
+ ss["sites"] = [
103
+ {
104
+ "Site Name": "Home",
105
+ "Site Coordinates": "",
106
+ "lat": None,
107
+ "lon": None,
108
+ "Load Bearing Capacity": None,
109
+ "Skin Shear Strength": None,
110
+ "Relative Compaction": None,
111
+ "Rate of Consolidation": None,
112
+ "Nature of Construction": None,
113
+ "Soil Profile": None,
114
+ "Flood Data": None,
115
+ "Seismic Data": None,
116
+ "Topography": None,
117
+ "Environmental Data": None,
118
+ "GSD": None,
119
+ "USCS": None,
120
+ "AASHTO": None,
121
+ "GI": None,
122
+ "classifier_inputs": {},
123
+ "classifier_decision_path": "",
124
+ "chat_history": [], # list of {role,msg,ts}
125
+ "report_convo_state": 0,
126
+ "map_snapshot": None, # store bytes of PNG
127
+ "ocr_text": None,
128
+ "lab_results": [], # list of lab dicts
129
+ "cbr_results": [], # list of cbr dicts
130
+ }
131
+ ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
+ if "active_site" not in ss:
134
+ ss["active_site"] = 0
 
 
135
 
136
+ if "model_name" not in ss:
137
+ # default model (you asked to support multiple LLMs; Groq model is set when calling)
138
+ ss["model_name"] = "meta-llama/llama-4-maverick-17b-128e-instruct"
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
+ # helper to get active site dict
141
+ def active_site() -> Dict[str, Any]:
142
+ idx = ss.get("active_site", 0)
143
+ idx = max(0, min(idx, len(ss["sites"]) - 1))
144
+ ss["active_site"] = idx
145
+ return ss["sites"][idx]
146
 
147
+ # ---------------------------
148
+ # Utility functions
149
+ # ---------------------------
150
+ def human_now() -> str:
151
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
 
 
 
152
 
153
+ def save_chat_message(site_idx: int, role: str, text: str):
154
+ ss["sites"][site_idx]["chat_history"].append({"role": role, "text": text, "ts": human_now()})
 
 
155
 
156
+ def ensure_float(x, default=0.0):
157
+ try:
158
+ return float(x)
159
+ except Exception:
160
+ return default
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
+ def ensure_int(x, default=0):
163
+ try:
164
+ return int(x)
165
+ except Exception:
166
+ return default
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
+ # ---------------------------
169
+ # USCS & AASHTO verbatim logic (as requested)
170
+ # ---------------------------
171
+ from math import floor
 
 
 
 
 
172
 
173
+ ENGINEERING_CHARACTERISTICS = {
174
+ "Gravel": {
175
+ "Settlement": "None",
176
+ "Quicksand": "Impossible",
177
+ "Frost-heaving": "None",
178
+ "Groundwater_lowering": "Possible",
179
+ "Cement_grouting": "Possible",
180
+ "Silicate_bitumen_injections": "Unsuitable",
181
+ "Compressed_air": "Possible (see notes)"
182
+ },
183
+ "Coarse sand": {
184
+ "Settlement": "None",
185
+ "Quicksand": "Impossible",
186
+ "Frost-heaving": "None",
187
+ "Groundwater_lowering": "Possible",
188
+ "Cement_grouting": "Possible only if very coarse",
189
+ "Silicate_bitumen_injections": "Suitable",
190
+ "Compressed_air": "Suitable"
191
+ },
192
+ "Medium sand": {
193
+ "Settlement": "None",
194
+ "Quicksand": "Unlikely",
195
+ "Frost-heaving": "None",
196
+ "Groundwater_lowering": "Suitable",
197
+ "Cement_grouting": "Impossible",
198
+ "Silicate_bitumen_injections": "Suitable",
199
+ "Compressed_air": "Suitable"
200
+ },
201
+ "Fine sand": {
202
+ "Settlement": "None",
203
+ "Quicksand": "Liable",
204
+ "Frost-heaving": "None",
205
+ "Groundwater_lowering": "Suitable",
206
+ "Cement_grouting": "Impossible",
207
+ "Silicate_bitumen_injections": "Not possible in very fine sands",
208
+ "Compressed_air": "Suitable"
209
+ },
210
+ "Silt": {
211
+ "Settlement": "Occurs",
212
+ "Quicksand": "Liable (very coarse silts may behave differently)",
213
+ "Frost-heaving": "Occurs",
214
+ "Groundwater_lowering": "Generally not suitable (electro-osmosis possible)",
215
+ "Cement_grouting": "Impossible",
216
+ "Silicate_bitumen_injections": "Impossible",
217
+ "Compressed_air": "Suitable"
218
+ },
219
+ "Clay": {
220
+ "Settlement": "Occurs",
221
+ "Quicksand": "Impossible",
222
+ "Frost-heaving": "None",
223
+ "Groundwater_lowering": "Impossible (generally)",
224
+ "Cement_grouting": "Only in stiff fissured clay",
225
+ "Silicate_bitumen_injections": "Impossible",
226
+ "Compressed_air": "Used for support only in special cases"
227
+ }
228
+ }
229
 
230
+ def uscs_aashto_verbatim(inputs: Dict[str, Any]) -> Tuple[str,str,str,int,Dict[str,str]]:
 
 
 
 
231
  """
232
+ Returns (result_text, uscs_sym, aashto_sym, GI, char_summary)
233
+ Uses your original logic, adapted for numeric inputs.
234
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  opt = str(inputs.get("opt","n")).lower()
236
  if opt == 'y':
237
  uscs = "Pt"
238
  uscs_expl = "Peat / organic soil — compressible, high organic content; poor engineering properties for load-bearing without special treatment."
239
  aashto = "Organic (special handling)"
240
+ characteristics = {"summary":"Highly organic peat — large settlement, low strength, not suitable for foundations without improvement."}
241
  result_text = f"According to USCS, the soil is {uscs} — {uscs_expl}\nAccording to AASHTO, the soil is {aashto}."
242
+ return result_text, uscs, aashto, 0, characteristics
243
+
244
+ P2 = ensure_float(inputs.get("P2", 0.0))
245
+ P4 = ensure_float(inputs.get("P4", 0.0))
246
+ D60 = ensure_float(inputs.get("D60", 0.0))
247
+ D30 = ensure_float(inputs.get("D30", 0.0))
248
+ D10 = ensure_float(inputs.get("D10", 0.0))
249
+ LL = ensure_float(inputs.get("LL", 0.0))
250
+ PL = ensure_float(inputs.get("PL", 0.0))
 
251
  PI = LL - PL
252
 
253
+ Cu = (D60 / D10) if (D10 > 0 and D60 > 0) else 0.0
254
+ Cc = ((D30 ** 2) / (D10 * D60)) if (D10 > 0 and D30 > 0 and D60 > 0) else 0.0
255
 
256
+ uscs = "Unknown"
257
+ uscs_expl = ""
258
+ # logic as in your provided script
259
  if P2 <= 50:
260
+ # Coarse-Grained Soils
261
  if P4 <= 50:
262
  # Gravels
263
  if Cu and Cc:
264
  if Cu >= 4 and 1 <= Cc <= 3:
265
+ uscs = "GW"; uscs_expl = "Well-graded gravel (good engineering properties, high strength, good drainage)."
266
  else:
267
+ uscs = "GP"; uscs_expl = "Poorly-graded gravel (less favorable gradation)."
268
  else:
269
  if PI < 4 or PI < 0.73 * (LL - 20):
270
+ uscs = "GM"; uscs_expl = "Silty gravel (fines may reduce permeability and strength)."
271
  elif PI > 7 and PI > 0.73 * (LL - 20):
272
+ uscs = "GC"; uscs_expl = "Clayey gravel (clayey fines increase plasticity, reduce strength)."
273
  else:
274
+ uscs = "GM-GC"; uscs_expl = "Gravel with mixed silt/clay fines."
275
  else:
276
  # Sands
277
  if Cu and Cc:
278
  if Cu >= 6 and 1 <= Cc <= 3:
279
+ uscs = "SW"; uscs_expl = "Well-graded sand (good compaction and drainage)."
280
  else:
281
+ uscs = "SP"; uscs_expl = "Poorly-graded sand (uniform or gap-graded)."
282
  else:
283
  if PI < 4 or PI <= 0.73 * (LL - 20):
284
+ uscs = "SM"; uscs_expl = "Silty sand (fines are low-plasticity silt)."
285
  elif PI > 7 and PI > 0.73 * (LL - 20):
286
+ uscs = "SC"; uscs_expl = "Clayey sand (clayey fines present; higher plasticity)."
287
  else:
288
+ uscs = "SM-SC"; uscs_expl = "Transition between silty sand and clayey sand."
289
  else:
290
+ # Fine-Grained Soils
291
  nDS = int(inputs.get("nDS", 5))
292
  nDIL = int(inputs.get("nDIL", 6))
293
  nTG = int(inputs.get("nTG", 6))
294
+
295
  if LL < 50:
296
  if 20 <= LL < 50 and PI <= 0.73 * (LL - 20):
297
  if nDS == 1 or nDIL == 3 or nTG == 3:
298
+ uscs = "ML"; uscs_expl = "Silt (low plasticity)."
299
  elif nDS == 3 or nDIL == 3 or nTG == 3:
300
+ uscs = "OL"; uscs_expl = "Organic silt (low plasticity)."
301
  else:
302
+ uscs = "ML-OL"; uscs_expl = "Mixed silt/organic silt."
303
  elif 10 <= LL <= 30 and 4 <= PI <= 7 and PI > 0.72 * (LL - 20):
304
  if nDS == 1 or nDIL == 1 or nTG == 1:
305
+ uscs = "ML"; uscs_expl = "Silt"
306
  elif nDS == 2 or nDIL == 2 or nTG == 2:
307
+ uscs = "CL"; uscs_expl = "Clay (low plasticity)."
308
  else:
309
+ uscs = "ML-CL"; uscs_expl = "Mixed silt/clay"
310
  else:
311
+ uscs = "CL"; uscs_expl = "Clay (low plasticity)."
312
  else:
313
  if PI < 0.73 * (LL - 20):
314
  if nDS == 3 or nDIL == 4 or nTG == 4:
315
+ uscs = "MH"; uscs_expl = "Silt (high plasticity)"
316
  elif nDS == 2 or nDIL == 2 or nTG == 4:
317
+ uscs = "OH"; uscs_expl = "Organic silt/clay (high plasticity)"
318
  else:
319
+ uscs = "MH-OH"; uscs_expl = "Mixed high-plasticity silt/organic"
320
  else:
321
+ uscs = "CH"; uscs_expl = "Clay (high plasticity)"
322
 
323
  # AASHTO logic
324
  if P2 <= 35:
 
347
  elif LL <= 40 and PI >= 11:
348
  aashto = "A-6"
349
  else:
350
+ if PI <= (LL - 30):
351
+ aashto = "A-7-5"
352
+ else:
353
+ aashto = "A-7-6"
354
 
355
+ # Group Index
356
  a = P2 - 35
357
  a = 0 if a < 0 else (40 if a > 40 else a)
358
  b = P2 - 15
 
363
  d = 0 if d < 0 else (20 if d > 20 else d)
364
  GI = floor(0.2 * a + 0.005 * a * c + 0.01 * b * d)
365
 
366
+ aashto_expl = f"{aashto} (Group Index = {GI})"
367
 
368
+ # engineering characteristics mapping best-effort
369
+ char_summary = {}
370
+ if uscs.startswith(("G", "P")):
371
+ char_summary = ENGINEERING_CHARACTERISTICS.get("Gravel", {})
372
+ elif uscs.startswith("S"):
373
+ char_summary = ENGINEERING_CHARACTERISTICS.get("Coarse sand", {})
374
+ elif uscs.startswith(("M", "C", "O", "H")):
375
  char_summary = ENGINEERING_CHARACTERISTICS.get("Silt", {})
376
 
377
+ result_text = f"According to USCS, the soil is {uscs} — {uscs_expl}\nAccording to AASHTO, the soil is {aashto_expl}"
 
 
 
378
  return result_text, uscs, aashto, GI, char_summary
379
 
380
+ # ---------------------------
381
+ # GSD utilities
382
+ # ---------------------------
383
+ def compute_gsd(diameters: List[float], passing: List[float]) -> Dict[str, Any]:
384
+ """
385
+ diameters: list descending (mm)
386
+ passing: percent passing corresponding to diameters
387
+ Returns D10,D30,D60,Cu,Cc and matplotlib figure
388
+ """
389
+ # ensure arrays sorted descending diameter
390
+ arr = sorted(zip(diameters, passing), key=lambda x: -x[0])
391
+ d_sorted = np.array([a for a,b in arr])
392
+ p_sorted = np.array([b for a,b in arr])
393
+
394
+ # interpolation function: percent -> diameter (we need diameter at percent passing)
395
+ def diameter_at(pct):
396
+ if pct <= p_sorted.min():
397
+ return d_sorted[p_sorted.argmin()]
398
+ if pct >= p_sorted.max():
399
+ return d_sorted[p_sorted.argmax()]
400
+ return float(np.interp(pct, p_sorted[::-1], d_sorted[::-1]))
401
+
402
+ D10 = diameter_at(10)
403
+ D30 = diameter_at(30)
404
+ D60 = diameter_at(60)
405
+ Cu = (D60 / D10) if (D10 > 0) else 0.0
406
+ Cc = (D30**2) / (D10 * D60) if (D10 > 0 and D60 > 0) else 0.0
407
+
408
+ # produce plot
409
+ fig, ax = plt.subplots(figsize=(6, 3.5))
410
+ ax.semilogx(d_sorted, p_sorted, marker='o', linestyle='-')
411
+ ax.set_xlabel("Grain size (mm)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  ax.set_ylabel("% Passing")
413
+ ax.set_title("Grain Size Distribution")
414
+ ax.grid(True, which="both", ls="--", lw=0.5)
415
+ plt.gca().invert_xaxis()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
 
417
+ return {"D10": D10, "D30": D30, "D60": D60, "Cu": Cu, "Cc": Cc, "fig": fig}
 
 
418
 
419
+ # ---------------------------
420
+ # Small LLM caller wrapper (Groq) — placeholder
421
+ # ---------------------------
422
+ def call_groq_system(prompt: str, model: Optional[str] = None) -> str:
423
+ """
424
+ Call Groq if available; otherwise return a simple deterministic response.
425
+ This is a thin wrapper: production usage requires proper auth and client.
426
+ """
427
+ model_to_use = model or ss.get("model_name") or "meta-llama/llama-4-maverick-17b-128e-instruct"
428
+ if HAVE_GROQ and GROQ_API_KEY:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  try:
430
+ client = Groq(api_key=GROQ_API_KEY)
431
+ completion = client.chat.completions.create(
432
+ model=model_to_use,
433
+ messages=[{"role": "user", "content": prompt}],
434
+ temperature=0.2
435
+ )
436
+ return completion.choices[0].message.content
437
  except Exception as e:
438
+ return f"(Groq call failed: {e})"
439
+ # fallback: echo summary-like response
440
+ return f"(LLM unavailable) I received your prompt. Summary: {prompt[:400]}..."
441
+
442
+ # ---------------------------
443
+ # Simple extractor to pull numeric parameters from free text
444
+ # ---------------------------
445
+ NUM_RE = re.compile(r"(-?\d+\.?\d*)\s*(k?pa|psf|%|mm|m)?", re.IGNORECASE)
446
+ def extract_parameters_from_text(text: str) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  """
448
+ Primitive entity extraction: searches for tokens like 'bearing 200 kPa', 'CBR 8', etc.
449
+ This is heuristic expand for production.
450
  """
451
+ out = {}
452
+ txt = text.lower()
453
+ # patterns of interest
454
+ patterns = {
455
+ "Load Bearing Capacity": r"(bearing capacity|bearing)\s*(?:is|:)?\s*([0-9]+\.?[0-9]*)\s*(kpa|psf)?",
456
+ "Relative Compaction": r"(compaction|relative compaction)\s*(?:is|:)?\s*([0-9]{1,3})\s*%",
457
+ "Skin Shear Strength": r"(skin shear|skin strength)\s*(?:is|:)?\s*([0-9]+\.?[0-9]*)\s*(kpa)?",
458
+ "CBR": r"cbr\s*(?:is|:)?\s*([0-9]+\.?[0-9]*)",
459
+ "Liquid Limit": r"(liquid limit|ll)\s*(?:is|:)?\s*([0-9]+\.?[0-9]*)",
460
+ "Plastic Limit": r"(plastic limit|pl)\s*(?:is|:)?\s*([0-9]+\.?[0-9]*)",
461
+ "D10": r"d10\s*(?:is|:)?\s*([0-9]+\.?[0-9]*)",
462
+ "D30": r"d30\s*(?:is|:)?\s*([0-9]+\.?[0-9]*)",
463
+ "D60": r"d60\s*(?:is|:)?\s*([0-9]+\.?[0-9]*)",
464
+ }
465
+ for key, pat in patterns.items():
466
+ m = re.search(pat, text, re.IGNORECASE)
467
+ if m:
468
+ val = float(m.group(2))
469
+ out[key] = val
470
+ # fallback: capture any numbers
471
+ nums = [float(m.group(1)) for m in NUM_RE.finditer(text)][:6]
472
+ if nums and not out:
473
+ out["misc_numbers"] = nums
474
+ return out
475
+
476
+ # ---------------------------
477
+ # PDF Builders (ReportLab for full report)
478
+ # ---------------------------
479
+ def build_full_geotech_pdf(site: Dict[str, Any], filename: str, external_refs: List[str] = []):
480
+ """
481
+ Build a professional PDF of the full geotechnical report using ReportLab.
482
+ """
483
+ buffer = io.BytesIO()
484
+ doc = SimpleDocTemplate(buffer, pagesize=A4, leftMargin=20*mm, rightMargin=20*mm, topMargin=25*mm, bottomMargin=20*mm)
485
+ styles = getSampleStyleSheet()
486
+ title_style = ParagraphStyle("Title", parent=styles["Title"], fontSize=20, alignment=1, textColor=colors.HexColor("#FF7A00"))
487
+ h1 = ParagraphStyle("H1", parent=styles["Heading1"], fontSize=14, textColor=colors.HexColor("#1F4E79"))
488
+ body = ParagraphStyle("Body", parent=styles["BodyText"], fontSize=10.5, leading=13)
489
+
490
+ elems = []
491
+
492
+ # Cover
493
+ elems.append(Paragraph(f"GEOTECHNICAL INVESTIGATION REPORT", title_style))
494
+ elems.append(Spacer(1,6))
495
+ elems.append(Paragraph(f"<b>Project:</b> {site.get('Site Name','-')}", body))
496
+ elems.append(Paragraph(f"<b>Date:</b> {datetime.today().strftime('%Y-%m-%d')}", body))
497
+ elems.append(Spacer(1,12))
498
+
499
+ # Sections following your template
500
+ elems.append(Paragraph("1.0 INTRODUCTION", h1))
501
+ intro_text = f"This report presents the results of the geotechnical investigation for {site.get('Site Name','the site')}."
502
+ elems.append(Paragraph(intro_text, body))
503
+ elems.append(Spacer(1,6))
504
+
505
+ elems.append(Paragraph("2.0 SITE DESCRIPTION AND GEOLOGY", h1))
506
+ site_desc = f"Coordinates: {site.get('lat','-')}, {site.get('lon','-')} - Topography: {site.get('Topography','-')}. Current land use: {site.get('Environmental Data','-')}."
507
+ elems.append(Paragraph(site_desc, body))
508
+ elems.append(Spacer(1,6))
509
+
510
+ elems.append(Paragraph("3.0 FIELD INVESTIGATION AND LABORATORY TESTING", h1))
511
+ field_txt = "Field program included test pits / boreholes and sample collection. See tables for lab results and CBR/compaction."
512
+ elems.append(Paragraph(field_txt, body))
513
+
514
+ # Lab table (if any)
515
+ lab_rows = site.get("lab_results", [])
516
+ if lab_rows:
517
+ elems.append(Spacer(1,6))
518
+