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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +879 -367
app.py CHANGED
@@ -1,266 +1,244 @@
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:
@@ -274,7 +252,7 @@ def uscs_aashto_verbatim(inputs: Dict[str, Any]) -> Tuple[str,str,str,int,Dict[s
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:
@@ -288,9 +266,9 @@ def uscs_aashto_verbatim(inputs: Dict[str, Any]) -> Tuple[str,str,str,int,Dict[s
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):
@@ -302,11 +280,11 @@ def uscs_aashto_verbatim(inputs: Dict[str, Any]) -> Tuple[str,str,str,int,Dict[s
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:
@@ -320,7 +298,7 @@ def uscs_aashto_verbatim(inputs: Dict[str, Any]) -> Tuple[str,str,str,int,Dict[s
320
  else:
321
  uscs = "CH"; uscs_expl = "Clay (high plasticity)"
322
 
323
- # AASHTO logic
324
  if P2 <= 35:
325
  if P2 <= 15 and P4 <= 30 and PI <= 6:
326
  aashto = "A-1-a"
@@ -347,172 +325,706 @@ def uscs_aashto_verbatim(inputs: Dict[str, Any]) -> Tuple[str,str,str,int,Dict[s
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
359
- b = 0 if b < 0 else (40 if b > 40 else b)
360
- c = LL - 40
361
- c = 0 if c < 0 else (20 if c > 20 else c)
362
- d = PI - 10
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
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py — GeoMate V2 (single file)
2
+ # ======================================================
3
+ # Copy-paste this whole file into your HuggingFace Space
4
+ # Then set secrets: GROQ_API_KEY, SERVICE_ACCOUNT (or EARTH_ENGINE_KEY)
5
+ # Optional secrets: FAISS_DB_ZIP (or upload via UI)
6
+ # ======================================================
 
 
 
 
 
 
 
 
 
 
 
7
 
8
+ # 0. Page config — MUST be first Streamlit call
9
  import streamlit as st
10
+ st.set_page_config(page_title="GeoMate V2", page_icon="🌍", layout="wide", initial_sidebar_state="expanded")
11
 
12
+ # 1. Standard imports
13
+ import os, json, io, zipfile, math, traceback
14
+ from datetime import datetime
15
+ from typing import Dict, Any, Tuple, List, Optional
 
 
 
 
 
 
 
 
 
 
16
 
17
+ # 2. Optional heavy imports guarded
18
  try:
19
+ import faiss
 
20
  except Exception:
21
+ faiss = None
22
 
23
  try:
24
+ from groq import Groq
 
25
  except Exception:
26
+ Groq = None
27
 
28
  try:
29
  import ee
30
+ import geemap
31
  except Exception:
32
+ ee = None
33
+ geemap = None
34
 
 
35
  try:
36
+ from reportlab.lib import colors
37
+ from reportlab.lib.pagesizes import A4
38
+ from reportlab.lib.units import mm
39
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak
40
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
41
  except Exception:
42
+ # we'll fallback to fpdf if reportlab is missing
43
+ try:
44
+ from fpdf import FPDF
45
+ except Exception:
46
+ FPDF = None
 
 
 
 
 
 
 
 
 
47
 
48
+ # 3. Basic CSS (rounded bubbles, orange active style)
49
+ st.markdown(
50
+ """
51
+ <style>
52
+ .bubble-bot {
53
+ background: linear-gradient(180deg,#111111,#1a1a1a);
54
+ color: #fff;
55
+ padding:10px 14px;
56
+ border-radius:12px;
57
+ border-left:4px solid #FF8C00;
58
+ margin:6px 0;
59
+ }
60
+ .bubble-user {
61
+ background: linear-gradient(180deg,#0f2b3a,#05202a);
62
+ color: #e6f7ff;
63
+ padding:10px 14px;
64
+ border-radius:12px;
65
+ margin:6px 0;
66
+ text-align:right;
67
+ }
68
+ .sidebar .stButton>button { border-radius:8px; }
69
+ .active-bubble { box-shadow: 0 0 0 3px rgba(255,122,0,0.12); }
70
+ </style>
71
+ """,
72
+ unsafe_allow_html=True,
73
+ )
74
 
75
+ # 4. REQUIRED secrets check (per your instructions)
76
+ REQUIRE_EE = True # change to False if you want to allow running without EE
77
+ REQUIRED_SECRETS = ["GROQ_API_KEY"]
78
+ if REQUIRE_EE:
79
+ REQUIRED_SECRETS.append("SERVICE_ACCOUNT") # or EARTH_ENGINE_KEY
 
80
 
81
+ missing = [s for s in REQUIRED_SECRETS if not os.getenv(s)]
82
  if missing:
83
+ st.error(f"Missing required secrets/environment variables: {missing}. Set them in HuggingFace Space Secrets or environment.")
 
 
 
84
  st.stop()
85
 
86
+ # 5. Load secrets into a dict for convenience
87
+ SECRETS = {k: os.getenv(k) for k in os.environ.keys() if k in REQUIRED_SECRETS + ["EARTH_ENGINE_KEY"]}
88
+ # Also optionally access other secrets by name directly via os.getenv
 
89
 
90
+ # 6. Session state alias and initialization
91
+ ss = st.session_state
 
92
  if "sites" not in ss:
93
+ # Initialize with one default site
94
+ ss["sites"] = [ {
95
+ "Site Name": "Home",
96
+ "Site Coordinates": "",
97
+ "lat": None, "lon": None,
98
+ "Load Bearing Capacity": None,
99
+ "Skin Shear Strength": None,
100
+ "Relative Compaction": None,
101
+ "Rate of Consolidation": None,
102
+ "Nature of Construction": None,
103
+ "Soil Profile": None,
104
+ "Flood Data": None,
105
+ "Seismic Data": None,
106
+ "Topography": None,
107
+ "GSD": None,
108
+ "USCS": None,
109
+ "AASHTO": None,
110
+ "GI": None,
111
+ "classifier_inputs": {},
112
+ "classifier_decision_path": "",
113
+ "chat_history": [],
114
+ "report_convo_state": 0,
115
+ "map_snapshot": None,
116
+ "classifier_chat": [],
117
+ } ]
118
+ if "active_site_index" not in ss:
119
+ ss["active_site_index"] = 0
120
+ if "llm_model" not in ss:
121
+ ss["llm_model"] = "meta-llama/llama-4-maverick-17b-128e-instruct"
122
+ if "faiss_loaded" not in ss:
123
+ ss["faiss_loaded"] = False
124
+ if "ee_inited" not in ss:
125
+ ss["ee_inited"] = False
 
 
 
 
 
126
 
127
+ # 7. Utility helpers
128
+ def now_str():
129
+ return datetime.utcnow().strftime("%Y-%m-%d_%H%M%S")
 
 
 
130
 
131
+ def get_active_site() -> Dict[str,Any]:
132
+ return ss["sites"][ss["active_site_index"]]
 
 
 
133
 
134
+ def save_site_field(site_idx:int, key:str, value):
135
+ ss["sites"][site_idx][key] = value
136
 
137
+ def map_pretty_sites():
138
+ return [s.get("Site Name","Site") for s in ss["sites"]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
+ # 8. Engineering characteristics table (full detailed set -> expanded)
141
  ENGINEERING_CHARACTERISTICS = {
142
  "Gravel": {
143
+ "Settlement": "Negligible",
144
+ "Quicksand": "No",
145
  "Frost-heaving": "None",
146
+ "Drainage": "Excellent",
147
+ "Cement grouting": "Possible",
148
+ "Notes": "Good bearing; suitable for foundations with minimal treatment."
 
149
  },
150
  "Coarse sand": {
151
+ "Settlement": "Negligible",
152
+ "Quicksand": "No",
153
  "Frost-heaving": "None",
154
+ "Drainage": "Excellent",
155
+ "Cement grouting": "Possible if coarse",
156
+ "Notes": "Good compaction properties; typical for pavement subgrade if dense."
 
157
  },
158
  "Medium sand": {
159
+ "Settlement": "Low",
160
  "Quicksand": "Unlikely",
161
  "Frost-heaving": "None",
162
+ "Drainage": "Good",
163
+ "Notes": "Moderate bearing; check gradation."
 
 
164
  },
165
  "Fine sand": {
166
+ "Settlement": "Potentially small",
167
+ "Quicksand": "Possible in presence of groundwater",
168
+ "Frost-heaving": "Depends on fines",
169
+ "Drainage": "Fair",
170
+ "Notes": "Uniform fine sands may be susceptible to piping under flow."
 
 
171
  },
172
  "Silt": {
173
+ "Settlement": "Moderate to high",
174
+ "Quicksand": "Possible (liquefaction risk under seismic)",
175
+ "Frost-heaving": "Likely",
176
+ "Drainage": "Poor",
177
+ "Notes": "Silty soils often need stabilization for foundations."
 
 
178
  },
179
  "Clay": {
180
+ "Settlement": "High (consolidation possible)",
181
+ "Quicksand": "No",
182
+ "Frost-heaving": "Less than silt",
183
+ "Drainage": "Poor",
184
+ "Notes": "Clayey soils require careful foundation design; may be expansive."
 
 
185
  }
186
  }
187
 
188
+ # 9. Verbatim USCS & AASHTO classifier (preserve logic)
189
+ from math import floor
190
+ def uscs_aashto_verbatim(inputs: Dict[str,Any]) -> Tuple[str, str, str, int, Dict[str,str]]:
191
  """
192
+ Inputs expected keys:
193
+ opt ('y'/'n'), P2, P4, D60, D30, D10, LL, PL, nDS, nDIL, nTG
194
+ Returns:
195
+ result_text (detailed natural text),
196
+ uscs_code, aashto_code, GI (int), char_summary (dict)
197
  """
198
+ # Read inputs (provide defaults)
199
  opt = str(inputs.get("opt","n")).lower()
200
+ if opt == "y":
201
  uscs = "Pt"
202
  uscs_expl = "Peat / organic soil — compressible, high organic content; poor engineering properties for load-bearing without special treatment."
203
  aashto = "Organic (special handling)"
204
+ GI = 0
205
+ char_summary = {"Notes":"Highly organic peatlarge settlement potential; unsuitable without improvement."}
206
+ res = f"According to USCS, the soil is {uscs} {uscs_expl}\nAccording to AASHTO, it is {aashto}."
207
+ return res, uscs, aashto, GI, char_summary
208
+
209
+ # numeric inputs
210
+ try:
211
+ P2 = float(inputs.get("P2",0.0))
212
+ except:
213
+ P2 = 0.0
214
+ try:
215
+ P4 = float(inputs.get("P4",0.0))
216
+ except:
217
+ P4 = 0.0
218
+ try:
219
+ D60 = float(inputs.get("D60",0.0))
220
+ D30 = float(inputs.get("D30",0.0))
221
+ D10 = float(inputs.get("D10",0.0))
222
+ except:
223
+ D60=D30=D10=0.0
224
+ try:
225
+ LL = float(inputs.get("LL",0.0))
226
+ PL = float(inputs.get("PL",0.0))
227
+ except:
228
+ LL=0.0; PL=0.0
229
  PI = LL - PL
230
 
231
+ Cu = (D60 / D10) if (D10>0 and D60>0) else 0.0
232
+ Cc = ((D30**2) / (D10*D60)) if (D10>0 and D30>0 and D60>0) else 0.0
233
 
234
  uscs = "Unknown"
235
  uscs_expl = ""
236
+ # USCS logic (verbatim from your script)
237
  if P2 <= 50:
238
  # Coarse-Grained Soils
239
  if P4 <= 50:
240
  # Gravels
241
+ if Cu!=0 and Cc!=0:
242
  if Cu >= 4 and 1 <= Cc <= 3:
243
  uscs = "GW"; uscs_expl = "Well-graded gravel (good engineering properties, high strength, good drainage)."
244
  else:
 
252
  uscs = "GM-GC"; uscs_expl = "Gravel with mixed silt/clay fines."
253
  else:
254
  # Sands
255
+ if Cu!=0 and Cc!=0:
256
  if Cu >= 6 and 1 <= Cc <= 3:
257
  uscs = "SW"; uscs_expl = "Well-graded sand (good compaction and drainage)."
258
  else:
 
266
  uscs = "SM-SC"; uscs_expl = "Transition between silty sand and clayey sand."
267
  else:
268
  # Fine-Grained Soils
269
+ nDS = int(inputs.get("nDS",5))
270
+ nDIL = int(inputs.get("nDIL",6))
271
+ nTG = int(inputs.get("nTG",6))
272
 
273
  if LL < 50:
274
  if 20 <= LL < 50 and PI <= 0.73 * (LL - 20):
 
280
  uscs = "ML-OL"; uscs_expl = "Mixed silt/organic silt."
281
  elif 10 <= LL <= 30 and 4 <= PI <= 7 and PI > 0.72 * (LL - 20):
282
  if nDS == 1 or nDIL == 1 or nTG == 1:
283
+ uscs = "ML"; uscs_expl = "Silt."
284
  elif nDS == 2 or nDIL == 2 or nTG == 2:
285
  uscs = "CL"; uscs_expl = "Clay (low plasticity)."
286
  else:
287
+ uscs = "ML-CL"; uscs_expl = "Mixed silt/clay."
288
  else:
289
  uscs = "CL"; uscs_expl = "Clay (low plasticity)."
290
  else:
 
298
  else:
299
  uscs = "CH"; uscs_expl = "Clay (high plasticity)"
300
 
301
+ # AASHTO logic (verbatim)
302
  if P2 <= 35:
303
  if P2 <= 15 and P4 <= 30 and PI <= 6:
304
  aashto = "A-1-a"
 
325
  elif LL <= 40 and PI >= 11:
326
  aashto = "A-6"
327
  else:
328
+ aashto = "A-7-5" if PI <= (LL - 30) else "A-7-6"
329
+
330
+ # Group Index (GI)
331
+ a = P2 - 35; a = 0 if a < 0 else (40 if a > 40 else a)
332
+ b = P2 - 15; b = 0 if b < 0 else (40 if b > 40 else b)
333
+ c = LL - 40; c = 0 if c < 0 else (20 if c > 20 else c)
334
+ d = PI - 10; d = 0 if d < 0 else (20 if d > 20 else d)
335
+ GI = floor(0.2*a + 0.005*a*c + 0.01*b*d)
 
 
 
 
 
 
 
336
 
337
  aashto_expl = f"{aashto} (Group Index = {GI})"
338
 
339
+ # Characteristics summary selection
340
  char_summary = {}
341
+ if uscs.startswith("G") or uscs.startswith("S"):
342
+ char_summary = ENGINEERING_CHARACTERISTICS.get("Coarse sand") or ENGINEERING_CHARACTERISTICS.get("Gravel")
343
+ elif uscs.startswith(("M","C","O","H")):
344
+ char_summary = ENGINEERING_CHARACTERISTICS.get("Silt")
345
+ else:
346
+ char_summary = {"Notes":"Engineering properties need site-specific testing."}
347
+
348
+ # Compose natural text
349
+ result_lines = []
350
+ result_lines.append(f"According to USCS, the soil is **{uscs}** — {uscs_expl}")
351
+ result_lines.append(f"According to AASHTO, the soil is **{aashto_expl}**.")
352
+ result_lines.append("Engineering characteristics summary:")
353
+ for k,v in char_summary.items():
354
+ result_lines.append(f"- {k}: {v}")
355
+
356
+ return "\n".join(result_lines), uscs, aashto, GI, char_summary
357
+
358
+ # 10. GSD compute & plot
359
+ import matplotlib.pyplot as plt
360
+ def compute_and_plot_gsd(diams: List[float], passing: List[float]) -> Dict[str,Any]:
361
+ """diams in mm descending, passing in %"""
362
+ import numpy as np
363
+ d = sorted(diams, reverse=True)
364
+ p = [max(0,min(100,float(x))) for x in passing]
365
+ # interpolation for D10 D30 D60
366
+ def interp_D(percent):
367
+ # percent is passing percent; we want diameter where percent passes
368
+ if percent <= p[-1]:
369
+ return d[-1]
370
+ if percent >= p[0]:
371
+ return d[0]
372
+ for i in range(len(p)-1):
373
+ if p[i] >= percent >= p[i+1]:
374
+ x0,x1 = p[i],p[i+1]
375
+ y0,y1 = d[i],d[i+1]
376
+ # linear interpolation in log space for better accuracy
377
+ if y0>0 and y1>0:
378
+ import math
379
+ logy0, logy1 = math.log(y0), math.log(y1)
380
+ t = (percent - x0)/(x1-x0) if x1!=x0 else 0
381
+ logy = logy0 + t*(logy1 - logy0)
382
+ return math.exp(logy)
383
+ else:
384
+ t = (percent-x0)/(x1-x0) if x1!=x0 else 0
385
+ return y0 + t*(y1-y0)
386
+ return d[-1]
387
+
388
+ D10 = interp_D(10)
389
+ D30 = interp_D(30)
390
+ D60 = interp_D(60)
391
+ Cu = (D60/D10) if D10>0 else None
392
+ Cc = ((D30**2)/(D10*D60)) if (D10>0 and D60>0) else None
393
+
394
+ fig = plt.figure(figsize=(6,3))
395
+ plt.semilogx(d, p, marker='o')
396
  plt.gca().invert_xaxis()
397
+ plt.xlabel("Particle diameter (mm) [log scale]")
398
+ plt.ylabel("% Passing")
399
+ plt.title("GSD Curve")
400
+ plt.grid(True, which="both", ls="--", alpha=0.4)
401
+ plt.tight_layout()
402
 
403
+ return {"D10":D10, "D30":D30, "D60":D60, "Cu":Cu, "Cc":Cc, "fig":fig}
404
 
405
+ # 11. OCR helper (pytesseract) — optional
406
+ def ocr_extract_image(img_file) -> Dict[str,Any]:
407
+ try:
408
+ from PIL import Image
409
+ import pytesseract
410
+ except Exception:
411
+ return {"error":"pytesseract or PIL not installed."}
412
+ try:
413
+ img = Image.open(img_file)
414
+ text = pytesseract.image_to_string(img)
415
+ # attempt to find numbers for LL, PL, sieve percentages using simple regex
416
+ import re
417
+ nums = re.findall(r"[-+]?\d*\.\d+|\d+", text)
418
+ return {"text":text, "numbers": nums}
419
+ except Exception as e:
420
+ return {"error":str(e)}
421
+
422
+ # 12. Sidebar UI (model selection, site management)
423
+ def sidebar_ui():
424
+ st.sidebar.markdown("### GeoMate V2")
425
+ st.sidebar.markdown("AI copilot for geotechnical engineering")
426
+
427
+ # LLM model selection
428
+ models = [
429
+ "meta-llama/llama-4-maverick-17b-128e-instruct",
430
+ "llama3-8b-8192",
431
+ "gemma-7b-it",
432
+ "mixtral-8x7b-32768"
433
+ ]
434
+ sel = st.sidebar.selectbox("Select LLM model", options=models, index=models.index(ss["llm_model"]) if ss["llm_model"] in models else 0)
435
+ ss["llm_model"] = sel
436
+
437
+ st.sidebar.markdown("---")
438
+ st.sidebar.markdown("#### Project Sites (max 4)")
439
+ # site list and management
440
+ site_names = map_pretty_sites()
441
+ st.sidebar.write("Active site:")
442
+ active = st.sidebar.radio("", options=site_names, index=ss["active_site_index"])
443
+ ss["active_site_index"] = site_names.index(active)
444
+
445
+ # Add site
446
+ if len(ss["sites"]) < 4:
447
+ new_name = st.sidebar.text_input("New site name", key="new_site_name_input")
448
+ if st.sidebar.button("➕ Add site") and new_name:
449
+ ss["sites"].append({
450
+ "Site Name": new_name,
451
+ "Site Coordinates": "",
452
+ "lat": None, "lon": None
453
+ })
454
+ ss["active_site_index"] = len(ss["sites"])-1
455
+ st.experimental_rerun()
456
+
457
+ # Show active site JSON
458
+ with st.sidebar.expander("Show active site JSON"):
459
+ st.json(get_active_site())
460
+
461
+ st.sidebar.markdown("---")
462
+ if st.sidebar.button("🔄 Reset Session (clear sites)"):
463
+ ss.clear()
464
+ st.experimental_rerun()
465
+
466
+ # 13. Landing page
467
+ def landing_ui():
468
+ st.markdown("<div style='display:flex;align-items:center;gap:16px'>"
469
+ "<div style='background:linear-gradient(135deg,#ff7a00,#ff3a3a); width:72px;height:72px;border-radius:16px;display:flex;align-items:center;justify-content:center'>"
470
+ "<span style='font-size:32px'>🛰️</span></div>"
471
+ "<div><h1 style='margin:0;color:#FF8C00'>GeoMate V2</h1>"
472
+ "<div style='color:#bfc9d9'>AI geotechnical copilot — Soil recognizer, classifier, locator, RAG, reports</div></div></div>",
473
+ unsafe_allow_html=True)
474
+ st.markdown("---")
475
+ st.markdown("""
476
+ **Quick actions**
477
+ """)
478
+ c1,c2,c3 = st.columns(3)
479
+ if c1.button("🧪 Classifier"):
480
+ ss["active_page"]="Soil Classifier"; st.rerun()
481
+ if c2.button("📊 GSD Curve"):
482
+ ss["active_page"]="GSD Curve"; st.rerun()
483
+ if c3.button("🌍 Locator"):
484
+ ss["active_page"]="Locator"; st.rerun()
485
+ st.markdown("---")
486
+ st.markdown("**Project summary**")
487
+ st.write(f"Sites configured: **{len(ss['sites'])}**")
488
+ st.write("Active site:")
489
+ st.write(get_active_site()["Site Name"])
490
+
491
+ # 14. Soil Recognizer (placeholder — image model integration)
492
+ def soil_recognizer_ui():
493
+ st.header("🖼️ Soil Recognizer (Image → Soil type)")
494
+ st.info("Upload an image of soil (photo or microscope). If you have a trained PyTorch model, upload `soil_best_model.pth` to the workspace and set model path below.")
495
+ img = st.file_uploader("Upload soil image", type=["png","jpg","jpeg"])
496
+ if img:
497
+ st.image(img, use_column_width=True)
498
+ st.success("Image received. (Model inference stub).")
499
+ # placeholder: run a stub classifier
500
+ st.markdown("**Predicted soil class (stub):** Silty clay (SC) — confidence: 0.72")
501
+
502
+ # 15. Soil Classifier — conversational wizard (chat style)
503
+ def soil_classifier_ui():
504
+ st.header("📋 Soil Classifier (Chat style)")
505
+
506
+ site_idx = ss["active_site_index"]
507
+ site = get_active_site()
508
+ if "classifier_step" not in site:
509
+ site["classifier_step"] = 0
510
+ site["classifier_inputs"] = {"opt":"n","P2":0.0,"P4":0.0,"D60":0.0,"D30":0.0,"D10":0.0,"LL":0.0,"PL":0.0,"nDS":5,"nDIL":6,"nTG":6}
511
+
512
+ step = site["classifier_step"]
513
+ ci = site["classifier_inputs"]
514
+
515
+ def bot(text):
516
+ st.markdown(f"<div class='bubble-bot'>{text}</div>", unsafe_allow_html=True)
517
+
518
+ def user(text):
519
+ st.markdown(f"<div class='bubble-user'>{text}</div>", unsafe_allow_html=True)
520
+
521
+ # Conversation steps (one at a time)
522
+ if step == 0:
523
+ bot("Hello — I am the GeoMate Soil Classifier. Ready to start? (This chat saves your answers automatically.)")
524
+ if st.button("Start classification"):
525
+ site["classifier_step"] = 1
526
+ st.rerun()
527
+ return
528
+
529
+ if step == 1:
530
+ bot("Is the soil organic (contains high organic matter, spongy, or odour)?")
531
+ col1,col2 = st.columns(2)
532
+ if col1.button("No"):
533
+ ci["opt"] = "n"
534
+ site["classifier_step"] = 2
535
+ st.rerun()
536
+ if col2.button("Yes"):
537
+ ci["opt"] = "y"
538
+ # If organic, we can classify as Pt and stop early
539
+ site["USCS"]="Pt"
540
+ site["AASHTO"]="Organic (special handling)"
541
+ site["classifier_decision_path"]="Organic branch (Pt)"
542
+ st.success("Soil marked as organic (Pt). Classification saved.")
543
+ return
544
+
545
+ if step == 2:
546
+ bot("Please enter the percentage passing the #200 sieve (0.075 mm). Example: 12")
547
+ val = st.number_input("Percentage passing #200", min_value=0.0, max_value=100.0, value=float(ci.get("P2",0.0)), step=1.0, format="%.2f", key=f"p2_{site_idx}")
548
+ if st.button("Confirm P2"):
549
+ ci["P2"]=float(val)
550
+ site["classifier_step"]=3
551
+ st.rerun()
552
+ return
553
+
554
+ if step == 3:
555
+ bot("What is the percentage passing sieve no. 4 (4.75 mm)?")
556
+ val = st.number_input("Percentage passing #4 (4.75 mm)", min_value=0.0, max_value=100.0, value=float(ci.get("P4",0.0)), step=1.0, format="%.2f", key=f"p4_{site_idx}")
557
+ if st.button("Confirm P4"):
558
+ ci["P4"]=float(val)
559
+ # decide branch: coarse or fine
560
+ if ci["P2"] <= 50:
561
+ site["classifier_step"]=4 # coarse branch asks D-values optionally
562
+ else:
563
+ site["classifier_step"]=6 # fine branch asks LL/PL
564
+ st.rerun()
565
+ return
566
+
567
+ if step == 4:
568
+ bot("Do you know D60, D30, and D10 (particle diameters in mm)?")
569
+ col1,col2 = st.columns(2)
570
+ if col1.button("Yes — I'll enter them"):
571
+ site["classifier_step"]=5
572
+ st.rerun()
573
+ if col2.button("No — I'll provide GSD later"):
574
+ # keep zeros
575
+ site["classifier_step"]=6
576
+ st.rerun()
577
+ return
578
+
579
+ if step == 5:
580
+ bot("Enter D60 (diameter in mm corresponding to 60% passing).")
581
+ D60 = st.number_input("D60 (mm)", min_value=0.0, value=float(ci.get("D60",0.0)), format="%.4f", key=f"d60_{site_idx}")
582
+ D30 = st.number_input("D30 (mm)", min_value=0.0, value=float(ci.get("D30",0.0)), format="%.4f", key=f"d30_{site_idx}")
583
+ D10 = st.number_input("D10 (mm)", min_value=0.0, value=float(ci.get("D10",0.0)), format="%.4f", key=f"d10_{site_idx}")
584
+ if st.button("Confirm D-values"):
585
+ ci["D60"]=float(D60); ci["D30"]=float(D30); ci["D10"]=float(D10)
586
+ site["classifier_step"]=6
587
+ st.rerun()
588
+ return
589
+
590
+ if step == 6:
591
+ bot("What is the Liquid Limit (LL)?")
592
+ LL = st.number_input("Liquid limit (LL)", min_value=0.0, max_value=200.0, value=float(ci.get("LL",0.0)), format="%.2f", key=f"ll_{site_idx}")
593
+ if st.button("Confirm LL"):
594
+ ci["LL"]=float(LL); site["classifier_step"]=7; st.rerun()
595
+ return
596
+
597
+ if step == 7:
598
+ bot("What is the Plastic Limit (PL)?")
599
+ PL = st.number_input("Plastic limit (PL)", min_value=0.0, max_value=200.0, value=float(ci.get("PL",0.0)), format="%.2f", key=f"pl_{site_idx}")
600
+ if st.button("Confirm PL"):
601
+ ci["PL"]=float(PL); site["classifier_step"]=8; st.rerun()
602
+ return
603
+
604
+ if step == 8:
605
+ bot("Select observed Dry Strength (text options).")
606
+ dry_options = ["None - slight","Medium - high","Slight - Medium","High - Very high","Null?"]
607
+ sel = st.selectbox("Dry strength", options=dry_options, index=dry_options.index("Slight - Medium") if ci.get("nDS") else 2, key=f"ds_{site_idx}")
608
+ # mapping to numbers consistent with logic: map to 1..5
609
+ dry_map = {"None - slight":1, "Medium - high":2, "Slight - Medium":3, "High - Very high":4, "Null?":5}
610
+ if st.button("Confirm dry strength"):
611
+ ci["nDS"]=dry_map[sel]; site["classifier_step"]=9; st.rerun()
612
+ return
613
+
614
+ if step == 9:
615
+ bot("Select observed Dilatancy (text options).")
616
+ dil_options = ["Quick to slow","None to very slow","Slow","Slow to none","None","Null?"]
617
+ sel = st.selectbox("Dilatancy", options=dil_options, index=0, key=f"dil_{site_idx}")
618
+ dil_map = {"Quick to slow":1,"None to very slow":2,"Slow":3,"Slow to none":4,"None":5,"Null?":6}
619
+ if st.button("Confirm dilatancy"):
620
+ ci["nDIL"]=dil_map[sel]; site["classifier_step"]=10; st.rerun()
621
+ return
622
+
623
+ if step == 10:
624
+ bot("Select observed Toughness (text options).")
625
+ tough_options = ["None","Medium","Slight?","Slight-Medium?","High","Null?"]
626
+ sel = st.selectbox("Toughness", options=tough_options, index=0, key=f"tg_{site_idx}")
627
+ tough_map = {"None":1,"Medium":2,"Slight?":3,"Slight-Medium?":4,"High":5,"Null?":6}
628
+ if st.button("Confirm toughness"):
629
+ ci["nTG"]=tough_map[sel]
630
+ # Now classify
631
+ res_text, uscs, aashto, GI, chars = uscs_aashto_verbatim(ci)
632
+ site["USCS"]=uscs; site["AASHTO"]=aashto; site["GI"]=GI
633
+ site["classifier_decision_path"]=res_text
634
+ site["classifier_step"]=11
635
+ st.success("Classification complete and saved.")
636
+ st.rerun()
637
+ return
638
+
639
+ if step == 11:
640
+ st.success("Classification finished.")
641
+ st.markdown("### Results")
642
+ st.markdown(site.get("classifier_decision_path","No decision path recorded"))
643
+ if st.button("Export classification PDF"):
644
+ fn = export_classification_pdf(site)
645
+ with open(fn,"rb") as f:
646
+ st.download_button("Download classification PDF", f, file_name=fn, mime="application/pdf")
647
+ # allow restart
648
+ if st.button("🔁 Start new classification"):
649
+ site["classifier_step"]=1
650
+ site["classifier_inputs"] = {"opt":"n","P2":0.0,"P4":0.0,"D60":0.0,"D30":0.0,"D10":0.0,"LL":0.0,"PL":0.0,"nDS":5,"nDIL":6,"nTG":6}
651
+ st.rerun()
652
+ return
653
+
654
+ # 16. GSD UI
655
+ def gsd_curve_ui():
656
+ st.header("📈 Grain Size Distribution (GSD) Curve")
657
+ site = get_active_site()
658
+ st.markdown("Enter diameters (mm) and % passing. Diameters should be in descending order (largest -> smallest).")
659
+ diam_input = st.text_area("Diameters (mm) comma-separated", value="75,50,37.5,25,19,12.5,9.5,4.75,2,0.85,0.425,0.25,0.18,0.15,0.075")
660
+ pass_input = st.text_area("% Passing (comma-separated)", value="100,98,96,90,85,78,72,65,55,45,35,25,18,14,8")
661
+ if st.button("Compute GSD"):
662
  try:
663
+ diams = [float(x.strip()) for x in diam_input.split(",") if x.strip()]
664
+ passing = [float(x.strip()) for x in pass_input.split(",") if x.strip()]
665
+ out = compute_and_plot_gsd(diams, passing)
666
+ fig = out["fig"]
667
+ st.pyplot(fig)
668
+ # store values
669
+ site["GSD"] = {"diameters":diams, "passing":passing, "D10":out["D10"], "D30":out["D30"], "D60":out["D60"], "Cu":out["Cu"], "Cc":out["Cc"]}
670
+ st.success(f"Saved GSD: D10={out['D10']:.4g}, D30={out['D30']:.4g}, D60={out['D60']:.4g}")
671
  except Exception as e:
672
+ st.error(f"GSD error: {e}\n{traceback.format_exc()}")
673
+
674
+ # 17. Locator UI (EE + geemap) robust with fallbacks
675
+ def locator_ui():
676
+ st.header("🌍 Locator — Draw AOI & fetch environmental datasets")
677
+ site = get_active_site()
678
+ # Try to initialize EE only if credentials available
679
+ EE_AVAILABLE = False
680
+ try:
681
+ if ee is not None and (os.getenv("SERVICE_ACCOUNT") or os.getenv("EARTH_ENGINE_KEY")):
682
+ # Initialize
683
+ if not ss["ee_inited"]:
684
+ try:
685
+ # if SERVICE_ACCOUNT holds json key or email
686
+ if os.getenv("EARTH_ENGINE_KEY"):
687
+ key_json = json.loads(os.getenv("EARTH_ENGINE_KEY"))
688
+ creds = ee.ServiceAccountCredentials(key_json.get("client_email"), key_json)
689
+ ee.Initialize(creds)
690
+ else:
691
+ sa = os.getenv("SERVICE_ACCOUNT")
692
+ sa_dict = json.loads(sa)
693
+ creds = ee.ServiceAccountCredentials(sa_dict.get("client_email"), sa_dict)
694
+ ee.Initialize(creds)
695
+ ss["ee_inited"] = True
696
+ except Exception as e:
697
+ st.error(f"Earth Engine init error: {e}")
698
+ EE_AVAILABLE = ss["ee_inited"]
699
+ except Exception as e:
700
+ st.warning(f"EE init attempt failed: {e}")
701
+
702
+ if EE_AVAILABLE and geemap is not None:
703
+ # interactive geemap
704
+ try:
705
+ m = geemap.Map(center=[0,0], zoom=2)
706
+ m.add_basemap("SATELLITE")
707
+ m.add_draw_control()
708
+ m.to_streamlit(height=500)
709
+ st.markdown("Use drawing tools to mark AOI then press Extract Data.")
710
+ if st.button("Extract Data from Earth Engine"):
711
+ # Here we provide STUBS and indicate where to replace with real queries
712
+ # Example: sample elevation from SRTM, fetch CHIRPS rainfall, seismic catalogs, flood layers...
713
+ try:
714
+ # STUB: Do some EE queries here to populate site dict.
715
+ site["Soil Profile"] = "Colluvial soils over weathered dolomite (EE sample)"
716
+ site["Flood Data"] = "No 20-year flood flagged (EE CHIRPS proxy)"
717
+ site["Seismic Data"] = "Historic PGA moderate (EE seismic catalog proxy)"
718
+ site["Topography"] = "Gentle slope; elevation approx. 250m (SRTM)"
719
+ st.success("Data extracted (stub). Replace STUB with real EE queries.")
720
+ except Exception as e:
721
+ st.error(f"Extraction failed: {e}")
722
+ except Exception as e:
723
+ st.error(f"Map rendering failed: {e}")
724
+ else:
725
+ st.warning("Earth Engine or geemap not available. You can still input coordinates manually.")
726
+ lat = st.number_input("Latitude", value=site.get("lat") or 0.0)
727
+ lon = st.number_input("Longitude", value=site.get("lon") or 0.0)
728
+ if st.button("Save coordinates"):
729
+ site["lat"]=lat; site["lon"]=lon
730
+ st.success("Coordinates saved.")
731
+
732
+ # 18. RAG Ask UI
733
+ def rag_ui():
734
+ st.header("🤖 GeoMate Ask (RAG + Groq)")
735
+ site = get_active_site()
736
+ st.markdown("This chat uses FAISS DB + Groq LLM. Upload a FAISS index folder (zipped) if not yet loaded.")
737
+ if not ss["faiss_loaded"]:
738
+ uploaded = st.file_uploader("Upload faiss_books_db.zip (index.faiss + meta.pkl)", type=["zip"])
739
+ if uploaded:
740
+ try:
741
+ with zipfile.ZipFile(uploaded, "r") as z:
742
+ z.extractall("faiss_db")
743
+ ss["faiss_loaded"] = True
744
+ st.success("FAISS DB extracted to ./faiss_db")
745
+ except Exception as e:
746
+ st.error(f"FAISS extraction error: {e}")
747
+ return
748
+ # show chat history
749
+ hist = site.get("chat_history",[])
750
+ for m in hist:
751
+ role = m.get("role"); text = m.get("text")
752
+ if role=="user":
753
+ st.markdown(f"<div class='bubble-user'>{text}</div>", unsafe_allow_html=True)
754
+ else:
755
+ st.markdown(f"<div class='bubble-bot'>{text}</div>", unsafe_allow_html=True)
756
+ # query input
757
+ q = st.text_input("Ask GeoMate (RAG):", key=f"rag_input_{site['Site Name']}")
758
+ if st.button("Send"):
759
+ if not q.strip():
760
+ st.warning("Type a question.")
761
+ else:
762
+ # append user
763
+ site.setdefault("chat_history", []).append({"role":"user","text":q})
764
+ # placeholder retrieval & LLM call
765
+ # 1) retrieval from FAISS (if available) -> gather top docs
766
+ retrieved = " (retrieved docs stub) "
767
+ # 2) call Groq (if available)
768
+ if Groq is None:
769
+ ans = "(Groq SDK missing) Answer stub: " + q + retrieved
770
+ else:
771
+ try:
772
+ client = Groq(api_key=os.getenv("GROQ_API_KEY"))
773
+ system = "You are GeoMate, technical geotechnical assistant. Use retrieved documents for deep answers. Use professional tone."
774
+ messages = [{"role":"system","content":system}, {"role":"user","content":q + "\n\nRetrieved docs:\n" + retrieved}]
775
+ resp = client.chat.completions.create(model=ss["llm_model"], messages=messages, temperature=0.2, max_tokens=1000)
776
+ ans = resp.choices[0].message.content
777
+ except Exception as e:
778
+ ans = f"(LLM call failed: {e})"
779
+ # append assistant
780
+ site.setdefault("chat_history", []).append({"role":"assistant","text":ans})
781
+ # attempt to auto-extract numeric/parameter info and save to site (simple heuristics)
782
+ lowq = q.lower()
783
+ try:
784
+ if "bearing" in lowq and any(ch.isdigit() for ch in q):
785
+ # crude parse last number
786
+ import re
787
+ nums = re.findall(r"[-+]?\d*\.\d+|\d+", q)
788
+ if nums:
789
+ site["Load Bearing Capacity"] = nums[-1]
790
+ if "compaction" in lowq and any(ch.isdigit() for ch in q):
791
+ import re
792
+ nums = re.findall(r"[-+]?\d*\.\d+|\d+", q)
793
+ if nums:
794
+ site["Relative Compaction"] = nums[-1]
795
+ except Exception:
796
+ pass
797
+ st.experimental_rerun()
798
+
799
+ # 19. Reports page — conversational gather + PDF exports (detailed)
800
+ def reports_ui():
801
+ st.header("📑 Reports — Classification & Full Geotechnical Report")
802
+ site = get_active_site()
803
+ st.markdown("Generate classification-only report or a full geotechnical investigation report.")
804
+ col1,col2 = st.columns(2)
805
+ with col1:
806
+ st.subheader("Classification-only Report")
807
+ if not site.get("classifier_decision_path"):
808
+ st.info("No classification saved. Use Soil Classifier page.")
809
+ else:
810
+ st.markdown("Classification result:")
811
+ st.write(site.get("classifier_decision_path"))
812
+ if st.button("Export Classification PDF"):
813
+ fn = export_classification_pdf(site)
814
+ with open(fn,"rb") as f:
815
+ st.download_button("Download Classification PDF", f, file_name=fn, mime="application/pdf")
816
+
817
+ with col2:
818
+ st.subheader("Full Geotechnical Report")
819
+ # show checklist of parameters we want
820
+ desired = [
821
+ "Load Bearing Capacity","Skin Shear Strength","Relative Compaction","Rate of Consolidation",
822
+ "Nature of Construction","Soil Profile","Flood Data","Seismic Data","Topography","GSD","USCS","AASHTO","GI"
823
+ ]
824
+ missing = [p for p in desired if not site.get(p)]
825
+ st.markdown(f"Missing parameters: **{len(missing)}**")
826
+ if st.button("Start conversational data collection"):
827
+ # set a convo state
828
+ site["report_convo_state"] = 0
829
+ st.experimental_rerun()
830
+
831
+ # conversational collector
832
+ if site.get("report_convo_state", None) is not None:
833
+ idx = site["report_convo_state"]
834
+ if idx < len(desired):
835
+ param = desired[idx]
836
+ st.markdown(f"**GeoMate:** Please provide **{param}** (or type 'skip'):")
837
+ entry = st.text_input(f"Enter {param}", key=f"rep_entry_{site['Site Name']}_{param}")
838
+ if st.button("Submit value", key=f"rep_submit_{param}"):
839
+ if entry.strip().lower() != "skip" and entry.strip() != "":
840
+ site[param] = entry.strip()
841
+ site["report_convo_state"] = idx + 1
842
+ st.experimental_rerun()
843
+ else:
844
+ st.success("Data collection complete.")
845
+ if st.button("Generate Full PDF Report"):
846
+ fn = export_full_geotech_pdf(site)
847
+ with open(fn,"rb") as f:
848
+ st.download_button("Download Full Geotechnical Report", f, file_name=fn, mime="application/pdf")
849
+ if st.button("Generate Dummy Full Report"):
850
+ fn = export_dummy_report(site)
851
+ with open(fn,"rb") as f:
852
+ st.download_button("Download Dummy Report", f, file_name=fn, mime="application/pdf")
853
+
854
+ # 20. PDF export helpers (classification & full geotech using ReportLab if available, fallback to FPDF)
855
+ def export_classification_pdf(site: Dict[str,Any]) -> str:
856
+ fn = f"{site.get('Site Name','site')}_classification_{now_str()}.pdf"
857
+ # Try ReportLab for nicer style
858
+ if 'reportlab' in globals() and reportlab_available := True:
859
+ try:
860
+ buf = io.BytesIO()
861
+ doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=18*mm, rightMargin=18*mm, topMargin=18*mm, bottomMargin=18*mm)
862
+ styles = getSampleStyleSheet()
863
+ elems = []
864
+ title_style = ParagraphStyle("title", parent=styles["Title"], fontSize=20, textColor=colors.HexColor("#FF7A00"))
865
+ elems.append(Paragraph("GeoMate Soil Classification Report", title_style))
866
+ elems.append(Spacer(1,6))
867
+ meta = f"Site: {site.get('Site Name','-')} • Date: {datetime.utcnow().strftime('%Y-%m-%d')}"
868
+ elems.append(Paragraph(meta, styles["Normal"]))
869
+ elems.append(Spacer(1,12))
870
+ elems.append(Paragraph("Classification result", styles["Heading2"]))
871
+ elems.append(Paragraph(site.get("classifier_decision_path","Not available"), styles["BodyText"]))
872
+ elems.append(Spacer(1,12))
873
+ elems.append(Paragraph("Inputs", styles["Heading3"]))
874
+ inputs = site.get("classifier_inputs",{})
875
+ data = [["Parameter","Value"]]
876
+ for k,v in inputs.items():
877
+ data.append([k,str(v)])
878
+ t = Table(data, colWidths=[80*mm,80*mm])
879
+ 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)]))
880
+ elems.append(t)
881
+ doc.build(elems)
882
+ with open(fn,"wb") as f:
883
+ f.write(buf.getvalue())
884
+ return fn
885
+ except Exception:
886
+ pass
887
+ # fallback to FPDF
888
+ if 'FPDF' in globals() and FPDF is not None:
889
+ pdf = FPDF()
890
+ pdf.add_page()
891
+ pdf.set_font("Arial","B",16)
892
+ pdf.cell(0,10,"GeoMate Soil Classification Report",ln=True,align='C')
893
+ pdf.set_font("Arial","",12)
894
+ pdf.ln(6)
895
+ pdf.multi_cell(0,8, f"Site: {site.get('Site Name')}\nDate: {datetime.utcnow().strftime('%Y-%m-%d')}")
896
+ pdf.ln(4)
897
+ pdf.multi_cell(0,8, "Classification Result:")
898
+ pdf.multi_cell(0,8, site.get("classifier_decision_path","Not available"))
899
+ # inputs
900
+ pdf.ln(4)
901
+ pdf.multi_cell(0,8,"Inputs:")
902
+ for k,v in site.get("classifier_inputs",{}).items():
903
+ pdf.cell(0,6,f"- {k}: {v}", ln=True)
904
+ pdf.output(fn)
905
+ return fn
906
+ # last fallback: write text file
907
+ with open(fn.replace(".pdf",".txt"), "w") as f:
908
+ f.write(site.get("classifier_decision_path","Not available"))
909
+ return fn
910
+
911
+ def export_full_geotech_pdf(site: Dict[str,Any]) -> str:
912
+ fn = f"{site.get('Site Name','site')}_FullGeotech_{now_str()}.pdf"
913
+ # Build a detailed PDF using ReportLab if possible
914
+ if 'reportlab' in globals():
915
+ try:
916
+ buf = io.BytesIO()
917
+ doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=18*mm, rightMargin=18*mm, topMargin=18*mm, bottomMargin=18*mm)
918
+ styles = getSampleStyleSheet()
919
+ elems=[]
920
+ title_style = ParagraphStyle("title", parent=styles["Title"], fontSize=18, textColor=colors.HexColor("#FF7A00"))
921
+ elems.append(Paragraph("Full Geotechnical Investigation Report", title_style))
922
+ elems.append(Spacer(1,8))
923
+ elems.append(Paragraph(f"Site: {site.get('Site Name','-')} • Date: {datetime.utcnow().strftime('%Y-%m-%d')}", styles["Normal"]))
924
+ elems.append(Spacer(1,10))
925
+ # 1. Project & Site
926
+ elems.append(Paragraph("1.0 Project & Site Information", styles["Heading2"]))
927
+ elems.append(Paragraph(f"Location: {site.get('Site Coordinates','Not provided')}", styles["BodyText"]))
928
+ elems.append(Spacer(1,8))
929
+ # 2. Field investigation
930
+ elems.append(Paragraph("2.0 Field Investigation & Observations", styles["Heading2"]))
931
+ fi_text = f"Soil Profile: {site.get('Soil Profile','Not provided')}\nFlood Data: {site.get('Flood Data','Not provided')}\nSeismic Data: {site.get('Seismic Data','Not provided')}\nTopography: {site.get('Topography','Not provided')}"
932
+ elems.append(Paragraph(fi_text.replace("\n","<br/>"), styles["BodyText"]))
933
+ elems.append(Spacer(1,8))
934
+ # 3. Lab results: show GSD table if available
935
+ elems.append(Paragraph("3.0 Laboratory Testing", styles["Heading2"]))
936
+ if site.get("GSD"):
937
+ g = site.get("GSD")
938
+ elems.append(Paragraph(f"GSD D10={g.get('D10')}, D30={g.get('D30')}, D60={g.get('D60')}, Cu={g.get('Cu')}, Cc={g.get('Cc')}", styles["BodyText"]))
939
+ else:
940
+ elems.append(Paragraph("GSD: Not provided", styles["BodyText"]))
941
+ elems.append(Spacer(1,8))
942
+ # 4. Analysis & Recommendations
943
+ elems.append(Paragraph("4.0 Evaluation & Recommendations", styles["Heading2"]))
944
+ # Quick evaluation based on USCS + other inputs
945
+ eval_lines=[]
946
+ eval_lines.append(f"USCS: {site.get('USCS','Not provided')}")
947
+ eval_lines.append(f"AASHTO: {site.get('AASHTO','Not provided')}")
948
+ eval_lines.append(f"Load bearing capacity: {site.get('Load Bearing Capacity','Not provided')}")
949
+ eval_lines.append("Recommendation: Preliminary—refer to detailed design after full testing.")
950
+ elems.append(Paragraph("<br/>".join(eval_lines), styles["BodyText"]))
951
+ elems.append(PageBreak())
952
+ doc.build(elems)
953
+ with open(fn,"wb") as f:
954
+ f.write(buf.getvalue())
955
+ return fn
956
+ except Exception as e:
957
+ st.error(f"ReportLab PDF build failed: {e}")
958
+ # fallback to simple FPDF
959
+ if 'FPDF' in globals() and FPDF is not None:
960
+ pdf = FPDF()
961
+ pdf.add_page()
962
+ pdf.set_font("Arial","B",16)
963
+ pdf.cell(0,10,"Full Geotechnical Investigation Report",ln=True,align="C")
964
+ pdf.set_font("Arial","",11)
965
+ pdf.ln(6)
966
+ for k,v in site.items():
967
+ if k in ["classifier_inputs","chat_history","classifier_decision_path"]:
968
+ continue
969
+ pdf.multi_cell(0,7,f"{k}: {v if v else 'Not Provided'}")
970
+ pdf.output(fn)
971
+ return fn
972
+ # else return text fallback
973
+ fn_txt = fn.replace(".pdf",".txt")
974
+ with open(fn_txt,"w") as f:
975
+ for k,v in site.items():
976
+ f.write(f"{k}: {v}\n")
977
+ return fn_txt
978
+
979
+ def export_dummy_report(site:Dict[str,Any]) -> str:
980
+ fn = f"{site.get('Site Name','site')}_Dummy_{now_str()}.pdf"
981
+ if 'FPDF' in globals() and FPDF is not None:
982
+ pdf = FPDF()
983
+ pdf.add_page()
984
+ pdf.set_font("Arial","B",18)
985
+ pdf.cell(0,10,"GeoMate — Dummy Geotechnical Report", ln=True, align="C")
986
+ pdf.ln(8)
987
+ pdf.set_font("Arial","",12)
988
+ pdf.multi_cell(0,8,"This dummy report is for layout testing. The final report will be more comprehensive and include charts, maps and tables.")
989
+ pdf.ln(6)
990
+ pdf.multi_cell(0,8,"Sample Conclusions:\n- Site is underlain by colluvial soils.\n- Recommended foundation: raft or piles depending on load.\n- Further testing (CPT, triaxial) recommended.")
991
+ pdf.output(fn)
992
+ return fn
993
+ else:
994
+ # fallback text
995
+ fn_txt = fn.replace(".pdf",".txt")
996
+ with open(fn_txt,"w") as f:
997
+ f.write("Dummy report (text fallback)\n")
998
+ return fn_txt
999
+
1000
+ # 21. Main page router
1001
+ PAGES = {
1002
+ "Landing": landing_ui,
1003
+ "Soil Recognizer": soil_recognizer_ui,
1004
+ "Soil Classifier": soil_classifier_ui,
1005
+ "GSD Curve": gsd_curve_ui,
1006
+ "Locator": locator_ui,
1007
+ "GeoMate Ask": rag_ui,
1008
+ "Reports": reports_ui
1009
+ }
1010
+
1011
+ def main():
1012
+ sidebar_ui()
1013
+ # top-level nav (use session page)
1014
+ if "active_page" not in ss:
1015
+ ss["active_page"]="Landing"
1016
+ # small nav bar at top
1017
+ cols = st.columns([1,3,1])
1018
+ with cols[1]:
1019
+ choice = st.selectbox("Open Page", options=list(PAGES.keys()), index=list(PAGES.keys()).index(ss["active_page"]))
1020
+ ss["active_page"] = choice
1021
+
1022
+ # call page function
1023
+ try:
1024
+ page_func = PAGES.get(ss["active_page"], landing_ui)
1025
+ page_func()
1026
+ except Exception as e:
1027
+ st.error(f"Page error: {e}\n{traceback.format_exc()}")
1028
+
1029
+ if __name__ == "__main__":
1030
+ main()