MSU576 commited on
Commit
d18eea8
Β·
verified Β·
1 Parent(s): 781bb54

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1120 -0
app.py ADDED
@@ -0,0 +1,1120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py β€” GeoMate V2 (single-file)
2
+ # Save this as app.py in your HuggingFace Space (or local folder)
3
+
4
+ # 0) Page config (must be first Streamlit command)
5
+ import streamlit as st
6
+ st.set_page_config(page_title="GeoMate V2", page_icon="🌍", layout="wide", initial_sidebar_state="expanded")
7
+
8
+ # 1) Standard imports
9
+ import os
10
+ import io
11
+ import json
12
+ import math
13
+ import base64
14
+ import tempfile
15
+ from datetime import datetime
16
+ from typing import Dict, Any, Tuple, List, Optional
17
+
18
+ # Visualization & PDF
19
+ import matplotlib.pyplot as plt
20
+ from reportlab.lib.pagesizes import A4
21
+ from reportlab.lib import colors
22
+ from reportlab.lib.units import mm
23
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image as RLImage, PageBreak
24
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
25
+
26
+ # Optional imports handled gracefully
27
+ try:
28
+ import geemap
29
+ import ee
30
+ EE_AVAILABLE = True
31
+ except Exception:
32
+ EE_AVAILABLE = False
33
+
34
+ try:
35
+ from fpdf import FPDF
36
+ FPDF_AVAILABLE = True
37
+ except Exception:
38
+ FPDF_AVAILABLE = False
39
+
40
+ try:
41
+ import faiss
42
+ FAISS_AVAILABLE = True
43
+ except Exception:
44
+ FAISS_AVAILABLE = False
45
+
46
+ try:
47
+ import pytesseract
48
+ from PIL import Image
49
+ OCR_AVAILABLE = True
50
+ except Exception:
51
+ OCR_AVAILABLE = False
52
+
53
+ # Groq client import β€” we will require key
54
+ try:
55
+ from groq import Groq
56
+ GROQ_AVAILABLE = True
57
+ except Exception:
58
+ GROQ_AVAILABLE = False
59
+
60
+ # 2) Secrets check (strict)
61
+ REQUIRED_SECRETS = ["GROQ_API_KEY", "SERVICE_ACCOUNT", "EARTH_ENGINE_KEY"]
62
+ missing = [s for s in REQUIRED_SECRETS if not os.environ.get(s)]
63
+ if missing:
64
+ st.sidebar.error(f"Missing required secrets: {', '.join(missing)}. Please add these to your HF Space secrets.")
65
+ st.error("Required secrets missing. Please set GROQ_API_KEY, SERVICE_ACCOUNT, and EARTH_ENGINE_KEY in Secrets and reload the app.")
66
+ st.stop()
67
+
68
+ # If Groq lib missing, still stop because user requested Groq usage
69
+ if not GROQ_AVAILABLE:
70
+ st.sidebar.error("Python package 'groq' not installed. Add it to requirements.txt and redeploy.")
71
+ st.error("Missing required library 'groq'. Please add to requirements and redeploy.")
72
+ st.stop()
73
+
74
+ # 3) Global constants & helper functions
75
+ MAX_SITES = 4
76
+
77
+ # Pre-defined dropdown text mappings (as you requested) β€” exact text with mapping numbers for logic backend
78
+ DILATANCY_OPTIONS = [
79
+ "1. Quick to slow",
80
+ "2. None to very slow",
81
+ "3. Slow",
82
+ "4. Slow to none",
83
+ "5. None",
84
+ "6. Null?"
85
+ ]
86
+ TOUGHNESS_OPTIONS = [
87
+ "1. None",
88
+ "2. Medium",
89
+ "3. Slight?",
90
+ "4. Slight to Medium?",
91
+ "5. High",
92
+ "6. Null?"
93
+ ]
94
+ DRY_STRENGTH_OPTIONS = [
95
+ "1. None to slight",
96
+ "2. Medium to high",
97
+ "3. Slight to Medium",
98
+ "4. High to very high",
99
+ "5. Null?"
100
+ ]
101
+
102
+ # Map option text to numeric codes used in your USCS logic
103
+ DILATANCY_MAP = {DILATANCY_OPTIONS[i]: i+1 for i in range(len(DILATANCY_OPTIONS))}
104
+ TOUGHNESS_MAP = {TOUGHNESS_OPTIONS[i]: i+1 for i in range(len(TOUGHNESS_OPTIONS))}
105
+ DRY_STRENGTH_MAP = {DRY_STRENGTH_OPTIONS[i]: i+1 for i in range(len(DRY_STRENGTH_OPTIONS))}
106
+
107
+ # Engineering characteristics dictionary (expanded earlier; trimmed to representative entries but detailed)
108
+ ENGINEERING_CHARACTERISTICS = {
109
+ "Gravel": {
110
+ "Settlement": "None",
111
+ "Quicksand": "Impossible",
112
+ "Frost-heaving": "None",
113
+ "Groundwater_lowering": "Possible",
114
+ "Cement_grouting": "Possible",
115
+ "Silicate_bitumen_injections": "Unsuitable",
116
+ "Compressed_air": "Possible (see notes)"
117
+ },
118
+ "Coarse sand": {
119
+ "Settlement": "None",
120
+ "Quicksand": "Impossible",
121
+ "Frost-heaving": "None",
122
+ "Groundwater_lowering": "Possible",
123
+ "Cement_grouting": "Possible only if very coarse",
124
+ "Silicate_bitumen_injections": "Suitable",
125
+ "Compressed_air": "Suitable"
126
+ },
127
+ "Medium sand": {
128
+ "Settlement": "None",
129
+ "Quicksand": "Unlikely",
130
+ "Frost-heaving": "None",
131
+ "Groundwater_lowering": "Suitable",
132
+ "Cement_grouting": "Impossible",
133
+ "Silicate_bitumen_injections": "Suitable",
134
+ "Compressed_air": "Suitable"
135
+ },
136
+ "Fine sand": {
137
+ "Settlement": "None",
138
+ "Quicksand": "Liable",
139
+ "Frost-heaving": "None",
140
+ "Groundwater_lowering": "Suitable",
141
+ "Cement_grouting": "Impossible",
142
+ "Silicate_bitumen_injections": "Not possible in very fine sands",
143
+ "Compressed_air": "Suitable"
144
+ },
145
+ "Silt": {
146
+ "Settlement": "Occurs",
147
+ "Quicksand": "Liable (very coarse silts may behave differently)",
148
+ "Frost-heaving": "Occurs",
149
+ "Groundwater_lowering": "Generally not suitable (electro-osmosis possible)",
150
+ "Cement_grouting": "Impossible",
151
+ "Silicate_bitumen_injections": "Impossible",
152
+ "Compressed_air": "Suitable"
153
+ },
154
+ "Clay": {
155
+ "Settlement": "Occurs",
156
+ "Quicksand": "Impossible",
157
+ "Frost-heaving": "None",
158
+ "Groundwater_lowering": "Impossible (generally)",
159
+ "Cement_grouting": "Only in stiff fissured clay",
160
+ "Silicate_bitumen_injections": "Impossible",
161
+ "Compressed_air": "Used for support only in special cases"
162
+ }
163
+ }
164
+
165
+ # USCS & AASHTO verbatim logic (function)
166
+ from math import floor
167
+
168
+ def classify_uscs_aashto(inputs: Dict[str, Any]) -> Tuple[str, str, int, Dict[str, str], str]:
169
+ """
170
+ Verbatim USCS + AASHTO classifier based on the logic you supplied.
171
+ inputs: dictionary expected keys:
172
+ opt: 'y' or 'n'
173
+ P2 (float): % passing #200 (0.075 mm)
174
+ P4 (float): % passing #4 (4.75 mm)
175
+ D60, D30, D10 (float mm) - can be 0 if unknown
176
+ LL, PL (float)
177
+ nDS, nDIL, nTG (int) mapped from dropdowns
178
+ Returns:
179
+ result_text (markdown), aashto_str, GI, engineering_characteristics (dict), uscs_str
180
+ """
181
+ opt = str(inputs.get("opt","n")).lower()
182
+ if opt == 'y':
183
+ uscs = "Pt"
184
+ uscs_expl = "Peat / organic soil β€” compressible, high organic content; poor engineering properties for load-bearing without special treatment."
185
+ aashto = "Organic (special handling)"
186
+ GI = 0
187
+ chars = {"summary":"Highly organic peat β€” large settlement, low strength, not suitable for foundations without improvement."}
188
+ res_text = f"According to USCS, the soil is **{uscs}** β€” {uscs_expl}\n\nAccording to AASHTO, the soil is **{aashto}**."
189
+ return res_text, aashto, GI, chars, uscs
190
+
191
+ # parse numeric inputs with defaults
192
+ P2 = float(inputs.get("P2", 0.0))
193
+ P4 = float(inputs.get("P4", 0.0))
194
+ D60 = float(inputs.get("D60", 0.0))
195
+ D30 = float(inputs.get("D30", 0.0))
196
+ D10 = float(inputs.get("D10", 0.0))
197
+ LL = float(inputs.get("LL", 0.0))
198
+ PL = float(inputs.get("PL", 0.0))
199
+ PI = LL - PL if (LL is not None and PL is not None) else 0.0
200
+
201
+ Cu = (D60 / D10) if (D10 > 0 and D60 > 0) else 0.0
202
+ Cc = ((D30 ** 2) / (D10 * D60)) if (D10 > 0 and D30 > 0 and D60 > 0) else 0.0
203
+
204
+ uscs = "Unknown"
205
+ uscs_expl = ""
206
+ if P2 <= 50:
207
+ # Coarse-Grained Soils
208
+ if P4 <= 50:
209
+ # Gravels
210
+ if Cu != 0 and Cc != 0:
211
+ if Cu >= 4 and 1 <= Cc <= 3:
212
+ uscs = "GW"; uscs_expl = "Well-graded gravel (good engineering properties, high strength, good drainage)."
213
+ else:
214
+ uscs = "GP"; uscs_expl = "Poorly-graded gravel (less favorable gradation)."
215
+ else:
216
+ if PI < 4 or PI < 0.73 * (LL - 20):
217
+ uscs = "GM"; uscs_expl = "Silty gravel (fines may reduce permeability and strength)."
218
+ elif PI > 7 and PI > 0.73 * (LL - 20):
219
+ uscs = "GC"; uscs_expl = "Clayey gravel (clayey fines increase plasticity)."
220
+ else:
221
+ uscs = "GM-GC"; uscs_expl = "Gravel with mixed silt/clay fines."
222
+ else:
223
+ # Sands
224
+ if Cu != 0 and Cc != 0:
225
+ if Cu >= 6 and 1 <= Cc <= 3:
226
+ uscs = "SW"; uscs_expl = "Well-graded sand (good compaction and drainage)."
227
+ else:
228
+ uscs = "SP"; uscs_expl = "Poorly-graded sand (uniform or gap-graded)."
229
+ else:
230
+ if PI < 4 or PI <= 0.73 * (LL - 20):
231
+ uscs = "SM"; uscs_expl = "Silty sand (fines are low-plasticity silt)."
232
+ elif PI > 7 and PI > 0.73 * (LL - 20):
233
+ uscs = "SC"; uscs_expl = "Clayey sand (clayey fines present; higher plasticity)."
234
+ else:
235
+ uscs = "SM-SC"; uscs_expl = "Transition between silty sand and clayey sand."
236
+ else:
237
+ # Fine-Grained Soils
238
+ nDS = int(inputs.get("nDS", 5))
239
+ nDIL = int(inputs.get("nDIL", 6))
240
+ nTG = int(inputs.get("nTG", 6))
241
+ if LL < 50:
242
+ if 20 <= LL < 50 and PI <= 0.73 * (LL - 20):
243
+ if nDS == 1 or nDIL == 3 or nTG == 3:
244
+ uscs = "ML"; uscs_expl = "Silt (low plasticity)."
245
+ elif nDS == 3 or nDIL == 3 or nTG == 3:
246
+ uscs = "OL"; uscs_expl = "Organic silt (low plasticity)."
247
+ else:
248
+ uscs = "ML-OL"; uscs_expl = "Mixed silt/organic silt."
249
+ elif 10 <= LL <= 30 and 4 <= PI <= 7 and PI > 0.72 * (LL - 20):
250
+ if nDS == 1 or nDIL == 1 or nTG == 1:
251
+ uscs = "ML"; uscs_expl = "Silt"
252
+ elif nDS == 2 or nDIL == 2 or nTG == 2:
253
+ uscs = "CL"; uscs_expl = "Clay (low plasticity)."
254
+ else:
255
+ uscs = "ML-CL"; uscs_expl = "Mixed silt/clay"
256
+ else:
257
+ uscs = "CL"; uscs_expl = "Clay (low plasticity)."
258
+ else:
259
+ if PI < 0.73 * (LL - 20):
260
+ if nDS == 3 or nDIL == 4 or nTG == 4:
261
+ uscs = "MH"; uscs_expl = "Silt (high plasticity)"
262
+ elif nDS == 2 or nDIL == 2 or nTG == 4:
263
+ uscs = "OH"; uscs_expl = "Organic silt/clay (high plasticity)"
264
+ else:
265
+ uscs = "MH-OH"; uscs_expl = "Mixed high-plasticity silt/organic"
266
+ else:
267
+ uscs = "CH"; uscs_expl = "Clay (high plasticity)"
268
+
269
+ # === AASHTO (verbatim) ===
270
+ if P2 <= 35:
271
+ if P2 <= 15 and P4 <= 30 and PI <= 6:
272
+ aashto = "A-1-a"
273
+ elif P2 <= 25 and P4 <= 50 and PI <= 6:
274
+ aashto = "A-1-b"
275
+ elif P2 <= 35 and P4 > 0:
276
+ if LL <= 40 and PI <= 10:
277
+ aashto = "A-2-4"
278
+ elif LL >= 41 and PI <= 10:
279
+ aashto = "A-2-5"
280
+ elif LL <= 40 and PI >= 11:
281
+ aashto = "A-2-6"
282
+ elif LL >= 41 and PI >= 11:
283
+ aashto = "A-2-7"
284
+ else:
285
+ aashto = "A-2"
286
+ else:
287
+ aashto = "A-3"
288
+ else:
289
+ if LL <= 40 and PI <= 10:
290
+ aashto = "A-4"
291
+ elif LL >= 41 and PI <= 10:
292
+ aashto = "A-5"
293
+ elif LL <= 40 and PI >= 11:
294
+ aashto = "A-6"
295
+ else:
296
+ aashto = "A-7-5" if PI <= (LL - 30) else "A-7-6"
297
+
298
+ # Group Index
299
+ a = P2 - 35
300
+ a = 0 if a < 0 else (40 if a > 40 else a)
301
+ b = P2 - 15
302
+ b = 0 if b < 0 else (40 if b > 40 else b)
303
+ c = LL - 40
304
+ c = 0 if c < 0 else (20 if c > 20 else c)
305
+ d = PI - 10
306
+ d = 0 if d < 0 else (20 if d > 20 else d)
307
+ GI = floor(0.2 * a + 0.005 * a * c + 0.01 * b * d)
308
+
309
+ aashto_expl = f"{aashto} (Group Index = {GI})"
310
+
311
+ # engineering characteristics pick
312
+ char_summary = {}
313
+ found_key = None
314
+ for key in ENGINEERING_CHARACTERISTICS:
315
+ if key.lower() in uscs.lower() or key.lower() in uscs_expl.lower():
316
+ found_key = key
317
+ break
318
+ if found_key:
319
+ char_summary = ENGINEERING_CHARACTERISTICS[found_key]
320
+ else:
321
+ # fallback selection by starting letter
322
+ if uscs.startswith("G") or uscs.startswith("S"):
323
+ char_summary = ENGINEERING_CHARACTERISTICS.get("Coarse sand", {})
324
+ else:
325
+ char_summary = ENGINEERING_CHARACTERISTICS.get("Silt", {})
326
+
327
+ res_text_lines = [
328
+ f"According to USCS, the soil is **{uscs}** β€” {uscs_expl}",
329
+ f"According to AASHTO, the soil is **{aashto_expl}**",
330
+ "",
331
+ "Engineering characteristics (summary):"
332
+ ]
333
+ for k,v in char_summary.items():
334
+ res_text_lines.append(f"- **{k}**: {v}")
335
+
336
+ result_text = "\n".join(res_text_lines)
337
+ return result_text, aashto_expl, GI, char_summary, uscs
338
+
339
+ # Helper: GSD interpolation to find diameters D10,D30,D60
340
+ def compute_gsd_metrics(diams: List[float], passing: List[float]) -> Dict[str, float]:
341
+ """
342
+ diams: list of diameters in mm (descending)
343
+ passing: corresponding % passing (0-100)
344
+ returns D10, D30, D60, Cu, Cc
345
+ """
346
+ # ensure descending diam, convert to float arrays
347
+ if len(diams) < 2 or len(diams) != len(passing):
348
+ raise ValueError("Diameters and passing arrays must match and have at least 2 items.")
349
+ # linear interpolation on log(d)
350
+ import numpy as np
351
+ d = np.array(diams)
352
+ p = np.array(passing)
353
+ # make sure p is decreasing or increasing? passing decreases as diameter decreases, but we will handle general interpolation by sorting by diameter descending
354
+ order = np.argsort(-d)
355
+ d = d[order]
356
+ p = p[order]
357
+ # drop duplicates etc
358
+ # function to find Dx = diameter at which passing = x (percent)
359
+ def find_D(x):
360
+ if x <= p.min():
361
+ return float(d[p.argmin()])
362
+ if x >= p.max():
363
+ return float(d[p.argmax()])
364
+ # linear interpolation on p vs log(d)
365
+ from math import log, exp
366
+ ld = np.log(d)
367
+ # interpolate ld as function of p
368
+ ld_interp = np.interp(x, p[::-1], ld[::-1]) # reverse because interp expects ascending x
369
+ return float(math.exp(ld_interp))
370
+ D10 = find_D(10.0)
371
+ D30 = find_D(30.0)
372
+ D60 = find_D(60.0)
373
+ Cu = D60 / D10 if D10 > 0 else 0.0
374
+ Cc = (D30 ** 2) / (D10 * D60) if (D10 > 0 and D60 > 0) else 0.0
375
+ return {"D10":D10, "D30":D30, "D60":D60, "Cu":Cu, "Cc":Cc}
376
+
377
+ # PDF builder (reportlab) β€” create professional document similar to uploaded sample
378
+ def build_full_geotech_pdf(site: Dict[str, Any], filename: str, include_map_image: Optional[bytes]=None, ext_refs: Optional[List[str]]=None):
379
+ """
380
+ site: dictionary of site data
381
+ filename: output file path
382
+ include_map_image: bytes of image to embed (optional)
383
+ ext_refs: list of external refs (strings)
384
+ """
385
+ styles = getSampleStyleSheet()
386
+ title_style = ParagraphStyle("title", parent=styles["Title"], fontSize=20, alignment=1, textColor=colors.HexColor("#FF7A00"))
387
+ h1 = ParagraphStyle("h1", parent=styles["Heading1"], fontSize=14, textColor=colors.HexColor("#1F4E79"), spaceAfter=6)
388
+ body = ParagraphStyle("body", parent=styles["BodyText"], fontSize=10.5, leading=13)
389
+ bullet = ParagraphStyle("bullet", parent=body, leftIndent=12, bulletIndent=6)
390
+ doc = SimpleDocTemplate(filename, pagesize=A4, leftMargin=18*mm, rightMargin=18*mm, topMargin=18*mm, bottomMargin=18*mm)
391
+ elems = []
392
+ # Cover
393
+ elems.append(Paragraph("GEOTECHNICAL INVESTIGATION REPORT", title_style))
394
+ elems.append(Spacer(1,6))
395
+ elems.append(Paragraph(f"<b>Project:</b> {site.get('Project Name','-')}", body))
396
+ elems.append(Paragraph(f"<b>Site:</b> {site.get('Site Name','-')}", body))
397
+ elems.append(Paragraph(f"<b>Date:</b> {datetime.today().strftime('%Y-%m-%d')}", body))
398
+ elems.append(Spacer(1,8))
399
+ elems.append(Paragraph("<b>Prepared by:</b> GeoMate AI", body))
400
+ elems.append(PageBreak())
401
+
402
+ # Summary
403
+ elems.append(Paragraph("SUMMARY", h1))
404
+ summary_bullets = [
405
+ f"Site: {site.get('Site Name','-')}.",
406
+ f"General geology: {site.get('Soil Profile','Not provided')}.",
407
+ f"Key lab tests: {', '.join([r.get('sampleId','') for r in site.get('Laboratory Results',[])]) if site.get('Laboratory Results') else 'No lab results provided.'}",
408
+ f"Classification: USCS = {site.get('USCS','Not provided')}; AASHTO = {site.get('AASHTO','Not provided')}.",
409
+ "Primary recommendation: See Recommendations section."
410
+ ]
411
+ for s in summary_bullets:
412
+ elems.append(Paragraph(f"β€’ {s}", bullet))
413
+ elems.append(PageBreak())
414
+
415
+ # 1.0 Introduction
416
+ elems.append(Paragraph("1.0 INTRODUCTION", h1))
417
+ intro_text = site.get("Project Description", "Project description not provided.")
418
+ elems.append(Paragraph(intro_text, body))
419
+
420
+ # 2.0 Site description and geology
421
+ elems.append(Paragraph("2.0 SITE DESCRIPTION AND GEOLOGY", h1))
422
+ site_geo = []
423
+ site_geo.append(f"Topography: {site.get('Topography','Not provided')}")
424
+ site_geo.append(f"Drainage: {site.get('Drainage','Not provided')}")
425
+ site_geo.append(f"Current land use: {site.get('Current Land Use','Not provided')}")
426
+ site_geo.append(f"Regional geology: {site.get('Regional Geology','Not provided')}")
427
+ for t in site_geo:
428
+ elems.append(Paragraph(t, body))
429
+ elems.append(PageBreak())
430
+
431
+ # 3.0 Field investigation and laboratory testing
432
+ elems.append(Paragraph("3.0 FIELD INVESTIGATION & LABORATORY TESTING", h1))
433
+ if site.get("Field Investigation"):
434
+ for item in site["Field Investigation"]:
435
+ elems.append(Paragraph(f"<b>{item.get('id','Test')}</b> β€” depth {item.get('depth','-')}", body))
436
+ for layer in item.get("layers",[]):
437
+ elems.append(Paragraph(f"- {layer.get('depth','')} : {layer.get('description','')}", body))
438
+ else:
439
+ elems.append(Paragraph("No field investigation data supplied.", body))
440
+ # Lab table
441
+ lab_rows = site.get("Laboratory Results", [])
442
+ if lab_rows:
443
+ elems.append(Spacer(1,6))
444
+ elems.append(Paragraph("Laboratory Results", h1))
445
+ data = [["Sample ID","Material","LL","PI","Linear Shrinkage","%Clay","%Silt","%Sand","%Gravel","Expansiveness"]]
446
+ for r in lab_rows:
447
+ data.append([
448
+ r.get("sampleId","-"),
449
+ r.get("material","-"),
450
+ str(r.get("liquidLimit","-")),
451
+ str(r.get("plasticityIndex","-")),
452
+ str(r.get("linearShrinkage","-")),
453
+ str(r.get("percentClay","-")),
454
+ str(r.get("percentSilt","-")),
455
+ str(r.get("percentSand","-")),
456
+ str(r.get("percentGravel","-")),
457
+ r.get("potentialExpansiveness","-")
458
+ ])
459
+ t = Table(data, repeatRows=1, colWidths=[40*mm,40*mm,18*mm,18*mm,22*mm,20*mm,20*mm,20*mm,20*mm,30*mm])
460
+ t.setStyle(TableStyle([
461
+ ('BACKGROUND',(0,0),(-1,0),colors.HexColor("#1F4E79")),
462
+ ('TEXTCOLOR',(0,0),(-1,0),colors.white),
463
+ ('GRID',(0,0),(-1,-1),0.4,colors.grey),
464
+ ('BOX',(0,0),(-1,-1),1,colors.HexColor("#FF7A00"))
465
+ ]))
466
+ elems.append(t)
467
+ elems.append(PageBreak())
468
+
469
+ # 4.0 Evaluation & 5.0 Classification & 6.0 Recommendations
470
+ elems.append(Paragraph("4.0 EVALUATION OF GEOTECHNICAL PROPERTIES", h1))
471
+ elems.append(Paragraph(site.get("Evaluation","Evaluation not provided."), body))
472
+ elems.append(Paragraph("5.0 PROVISIONAL SITE CLASSIFICATION", h1))
473
+ elems.append(Paragraph(site.get("Provisional Classification","Not provided."), body))
474
+ elems.append(Paragraph("6.0 RECOMMENDATIONS", h1))
475
+ elems.append(Paragraph(site.get("Recommendations","Not provided."), body))
476
+
477
+ # Map
478
+ if include_map_image:
479
+ try:
480
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
481
+ tmp.write(include_map_image)
482
+ tmp.flush()
483
+ elems.append(PageBreak())
484
+ elems.append(Paragraph("Map Snapshot", h1))
485
+ elems.append(RLImage(tmp.name, width=160*mm, height=90*mm))
486
+ except Exception:
487
+ pass
488
+
489
+ # External refs
490
+ if ext_refs:
491
+ elems.append(PageBreak())
492
+ elems.append(Paragraph("References", h1))
493
+ for r in ext_refs:
494
+ elems.append(Paragraph(f"- {r}", body))
495
+
496
+ doc.build(elems)
497
+ return filename
498
+
499
+ # 4) Session state initialization
500
+ if "sites" not in st.session_state:
501
+ # initialize with a default site
502
+ st.session_state["sites"] = [{
503
+ "Site Name":"Home",
504
+ "Project Name":"Demo Project",
505
+ "Site ID": 0,
506
+ "Coordinates":"",
507
+ "lat": None,
508
+ "lon": None,
509
+ "Project Description":"",
510
+ "Topography":"",
511
+ "Drainage":"",
512
+ "Current Land Use":"",
513
+ "Regional Geology":"",
514
+ "Field Investigation": [],
515
+ "Laboratory Results": [],
516
+ "GSD": None,
517
+ "USCS": None,
518
+ "AASHTO": None,
519
+ "GI": None,
520
+ "Load Bearing Capacity": None,
521
+ "Skin Shear Strength": None,
522
+ "Relative Compaction": None,
523
+ "Rate of Consolidation": None,
524
+ "Nature of Construction": None,
525
+ "Soil Profile": None,
526
+ "Flood Data": None,
527
+ "Seismic Data": None,
528
+ "Environmental Data": None,
529
+ "Topo Data": None,
530
+ "map_snapshot": None,
531
+ "chat_history": [],
532
+ "classifier_inputs": {},
533
+ "classifier_decision": None,
534
+ "report_convo_state": 0,
535
+ "report_missing_fields": [],
536
+ "report_answers": {}
537
+ }]
538
+
539
+ if "active_site" not in st.session_state:
540
+ st.session_state["active_site"] = 0
541
+
542
+ if "llm_model" not in st.session_state:
543
+ st.session_state["llm_model"] = "meta-llama/llama-4-maverick-17b-128e-instruct"
544
+
545
+ # Groq client (simple wrapper)
546
+ GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
547
+ def groq_generate(prompt: str, model: str = None, max_tokens: int = 512) -> str:
548
+ """Call Groq. If call fails, return an explanatory text."""
549
+ try:
550
+ client = Groq(api_key=GROQ_API_KEY)
551
+ model_name = model or st.session_state["llm_model"]
552
+ completion = client.chat.completions.create(
553
+ model=model_name,
554
+ messages=[{"role":"user","content":prompt}],
555
+ temperature=0.2,
556
+ max_tokens=max_tokens
557
+ )
558
+ text = completion.choices[0].message.content
559
+ return text
560
+ except Exception as e:
561
+ return f"[LLM error or offline: {e}]"
562
+
563
+ # 5) UI helper: nice CSS for chat bubbles & page styling
564
+ st.markdown("""
565
+ <style>
566
+ /* Background and card styling */
567
+ body { background: #0b0b0b; color: #e9eef6; }
568
+ .stApp > .main > .block-container { padding-top: 18px; }
569
+
570
+ /* Landing and cards */
571
+ .gm-card { background: linear-gradient(180deg, rgba(255,122,0,0.04), rgba(255,122,0,0.02)); border-radius:12px; padding:14px; border:1px solid rgba(255,122,0,0.06);}
572
+ .gm-cta { background: linear-gradient(90deg,#ff7a00,#ff3a3a); color:white; padding:10px 14px; border-radius:10px; font-weight:700; }
573
+
574
+ /* Chat bubbles */
575
+ .chat-bot { background: #0f1720; border-left:4px solid #FF7A00; padding:10px 12px; border-radius:12px; margin:6px 0; color:#e9eef6; }
576
+ .chat-user { background: #1a1f27; padding:10px 12px; border-radius:12px; margin:6px 0; color:#cfe6ff; text-align:right;}
577
+ .small-muted { color:#9aa7bf; font-size:12px; }
578
+ </style>
579
+ """, unsafe_allow_html=True)
580
+
581
+ # 6) Sidebar: navigation, site selector, model selector
582
+ from streamlit_option_menu import option_menu
583
+
584
+ with st.sidebar:
585
+ st.markdown("<h2 style='color:#FF8C00;margin:8px 0'>GeoMate V2</h2>", unsafe_allow_html=True)
586
+ # LLM model selector
587
+ st.session_state["llm_model"] = st.selectbox("Select LLM model", options=[
588
+ "meta-llama/llama-4-maverick-17b-128e-instruct",
589
+ "llama3-8b-8192",
590
+ "gemma-7b-it",
591
+ "mixtral-8x7b-32768"
592
+ ], index=0)
593
+ st.markdown("---")
594
+
595
+ # Site management controls
596
+ st.markdown("### Project Sites")
597
+ site_names = [s.get("Site Name", f"Site {i}") for i,s in enumerate(st.session_state["sites"])]
598
+ # Add new site input
599
+ new_site_name = st.text_input("New site name", value="", key="new_site_name_input")
600
+ if st.button("βž• Add / Create Site"):
601
+ if new_site_name.strip() == "":
602
+ st.warning("Enter a name for the new site.")
603
+ elif len(st.session_state["sites"]) >= MAX_SITES:
604
+ st.error(f"Maximum of {MAX_SITES} sites allowed.")
605
+ else:
606
+ idx = len(st.session_state["sites"])
607
+ st.session_state["sites"].append({
608
+ "Site Name": new_site_name.strip(),
609
+ "Project Name": "Project - " + new_site_name.strip(),
610
+ "Site ID": idx,
611
+ "Coordinates":"",
612
+ "lat": None,
613
+ "lon": None,
614
+ "Project Description":"",
615
+ "Topography":"",
616
+ "Drainage":"",
617
+ "Current Land Use":"",
618
+ "Regional Geology":"",
619
+ "Field Investigation": [],
620
+ "Laboratory Results": [],
621
+ "GSD": None,
622
+ "USCS": None,
623
+ "AASHTO": None,
624
+ "GI": None,
625
+ "Load Bearing Capacity": None,
626
+ "Skin Shear Strength": None,
627
+ "Relative Compaction": None,
628
+ "Rate of Consolidation": None,
629
+ "Nature of Construction": None,
630
+ "Soil Profile": None,
631
+ "Flood Data": None,
632
+ "Seismic Data": None,
633
+ "Environmental Data": None,
634
+ "Topo Data": None,
635
+ "map_snapshot": None,
636
+ "chat_history": [],
637
+ "classifier_inputs": {},
638
+ "classifier_decision": None,
639
+ "report_convo_state": 0,
640
+ "report_missing_fields": [],
641
+ "report_answers": {}
642
+ })
643
+ st.success(f"Site '{new_site_name.strip()}' created.")
644
+ st.session_state["active_site"] = idx
645
+ st.rerun()
646
+
647
+ # Active site selector
648
+ if site_names:
649
+ active_index = st.selectbox("Active Site", options=list(range(len(site_names))),
650
+ format_func=lambda x: site_names[x], index=st.session_state["active_site"])
651
+ st.session_state["active_site"] = active_index
652
+ st.markdown("---")
653
+ st.write("Active Site JSON (live)")
654
+ st.json(st.session_state["sites"][st.session_state["active_site"]])
655
+
656
+ st.markdown("---")
657
+ st.markdown("Β© GeoMate β€’ Advanced geotechnical copilot", unsafe_allow_html=True)
658
+
659
+ # 7) Pages implementation
660
+ def landing_page():
661
+ st.markdown("<div style='display:flex;align-items:center;gap:12px'>"
662
+ "<div style='width:76px;height:76px;border-radius:14px;background:linear-gradient(135deg,#ff7a00,#ff3a3a);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 24px rgba(0,0,0,0.6)'>"
663
+ "<span style='font-size:34px'>πŸ›°οΈ</span></div>"
664
+ "<div><h1 style='margin:0;color:#FF8C00'>GeoMate V2</h1>"
665
+ "<div class='small-muted'>AI geotechnical copilot β€” soil recognition, classification, locator, RAG, and reports</div></div></div>", unsafe_allow_html=True)
666
+ st.markdown("---")
667
+ col1, col2 = st.columns([2,1])
668
+ with col1:
669
+ st.markdown("<div class='gm-card'>", unsafe_allow_html=True)
670
+ st.write("GeoMate is built to help geotechnical engineers: classify soils (USCS/AASHTO), plot GSD, fetch Earth Engine data, chat with a RAG-backed LLM, and generate professional geotechnical reports.")
671
+ st.markdown("</div>", unsafe_allow_html=True)
672
+ st.markdown("### Quick Actions")
673
+ c1, c2, c3 = st.columns(3)
674
+ if c1.button("πŸ§ͺ Classifier"):
675
+ st.session_state["page"] = "Classifier"; st.rerun()
676
+ if c2.button("πŸ“ˆ GSD Curve"):
677
+ st.session_state["page"] = "GSD"; st.rerun()
678
+ if c3.button("🌍 Locator"):
679
+ st.session_state["page"] = "Locator"; st.rerun()
680
+ c4, c5, c6 = st.columns(3)
681
+ if c4.button("πŸ€– GeoMate Ask"):
682
+ st.session_state["page"] = "RAG"; st.rerun()
683
+ if c5.button("πŸ“· OCR"):
684
+ st.session_state["page"] = "OCR"; st.rerun()
685
+ if c6.button("πŸ“‘ Reports"):
686
+ st.session_state["page"] = "Reports"; st.rerun()
687
+ with col2:
688
+ st.markdown("<div class='gm-card' style='text-align:center'>", unsafe_allow_html=True)
689
+ st.markdown("<h3 style='color:#FF8C00'>Live Site Summary</h3>", unsafe_allow_html=True)
690
+ site = st.session_state["sites"][st.session_state["active_site"]]
691
+ st.write(f"Site: **{site.get('Site Name')}**")
692
+ st.write(f"USCS: {site.get('USCS')}, AASHTO: {site.get('AASHTO')}")
693
+ st.write(f"GSD saved: {'Yes' if site.get('GSD') else 'No'}")
694
+ st.markdown("</div>", unsafe_allow_html=True)
695
+
696
+ # Soil Classifier page (conversational, step-by-step)
697
+ def soil_classifier_page():
698
+ st.header("πŸ§ͺ Soil Classifier β€” Conversational (USCS & AASHTO)")
699
+ site = st.session_state["sites"][st.session_state["active_site"]]
700
+
701
+ # conversation state machine: steps list
702
+ steps = [
703
+ {"id":"intro", "bot":"Hello β€” I am the GeoMate Soil Classifier. Ready to start?"},
704
+ {"id":"organic", "bot":"Is the soil at this site organic (contains high organic matter, feels spongy or has odour)?", "type":"choice", "choices":["No","Yes"]},
705
+ {"id":"P2", "bot":"Please enter the percentage passing the #200 sieve (0.075 mm). Example: 12", "type":"number"},
706
+ {"id":"P4", "bot":"What is the percentage passing the sieve no. 4 (4.75 mm)? (enter 0 if unknown)", "type":"number"},
707
+ {"id":"hasD", "bot":"Do you know the D10, D30 and D60 diameters (in mm)?", "type":"choice","choices":["No","Yes"]},
708
+ {"id":"D60", "bot":"Enter D60 (diameter in mm corresponding to 60% passing).", "type":"number"},
709
+ {"id":"D30", "bot":"Enter D30 (diameter in mm corresponding to 30% passing).", "type":"number"},
710
+ {"id":"D10", "bot":"Enter D10 (diameter in mm corresponding to 10% passing).", "type":"number"},
711
+ {"id":"LL", "bot":"What is the liquid limit (LL)?", "type":"number"},
712
+ {"id":"PL", "bot":"What is the plastic limit (PL)?", "type":"number"},
713
+ {"id":"dry", "bot":"Select the observed dry strength of the fine soil (if applicable).", "type":"select", "options":DRY_STRENGTH_OPTIONS},
714
+ {"id":"dilat", "bot":"Select the observed dilatancy behaviour.", "type":"select", "options":DILATANCY_OPTIONS},
715
+ {"id":"tough", "bot":"Select the observed toughness.", "type":"select", "options":TOUGHNESS_OPTIONS},
716
+ {"id":"confirm", "bot":"Would you like me to classify now?", "type":"choice", "choices":["No","Yes"]}
717
+ ]
718
+
719
+ if "classifier_step" not in st.session_state:
720
+ st.session_state["classifier_step"] = 0
721
+ if "classifier_inputs" not in st.session_state:
722
+ st.session_state["classifier_inputs"] = dict(site.get("classifier_inputs", {}))
723
+
724
+ step_idx = st.session_state["classifier_step"]
725
+
726
+ # chat history display
727
+ st.markdown("<div class='gm-card'>", unsafe_allow_html=True)
728
+ st.markdown("<div class='chat-bot'>{}</div>".format("GeoMate: Hello β€” soil classifier ready. Use the controls below to answer step-by-step."), unsafe_allow_html=True)
729
+ # Show stored user answers sequentially for context
730
+ # render question up to current step
731
+ for i in range(step_idx+1):
732
+ s = steps[i]
733
+ # show bot prompt
734
+ st.markdown(f"<div class='chat-bot'>{s['bot']}</div>", unsafe_allow_html=True)
735
+ # show user answer if exists in classifier_inputs
736
+ key = s["id"]
737
+ val = st.session_state["classifier_inputs"].get(key)
738
+ if val is not None:
739
+ st.markdown(f"<div class='chat-user'>{val}</div>", unsafe_allow_html=True)
740
+
741
+ st.markdown("</div>", unsafe_allow_html=True)
742
+
743
+ # Render input widget for current step
744
+ current = steps[step_idx]
745
+ step_id = current["id"]
746
+ proceed = False
747
+ user_answer = None
748
+
749
+ cols = st.columns([1,1,1])
750
+ with cols[0]:
751
+ if current.get("type") == "choice":
752
+ choice = st.radio(current["bot"], options=current["choices"], index=0, key=f"cls_{step_id}")
753
+ user_answer = choice
754
+ elif current.get("type") == "number":
755
+ # numeric input without +/- spinner (we use text_input and validate)
756
+ raw = st.text_input(current["bot"], value=str(st.session_state["classifier_inputs"].get(step_id,"")), key=f"cls_{step_id}_num")
757
+ # validate numeric
758
+ try:
759
+ if raw.strip() == "":
760
+ user_answer = None
761
+ else:
762
+ user_answer = float(raw)
763
+ except:
764
+ st.warning("Please enter a valid number (e.g., 12 or 0).")
765
+ user_answer = None
766
+ elif current.get("type") == "select":
767
+ opts = current.get("options", [])
768
+ sel = st.selectbox(current["bot"], options=opts, index=0, key=f"cls_{step_id}_sel")
769
+ user_answer = sel
770
+ else:
771
+ # just a message step β€” proceed
772
+ user_answer = None
773
+
774
+ # controls: Next / Back
775
+ coln, colb, colsave = st.columns([1,1,1])
776
+ with coln:
777
+ if st.button("➑️ Next", key=f"next_{step_id}"):
778
+ # store answer if provided
779
+ if current.get("type") == "number":
780
+ if user_answer is None:
781
+ st.warning("Please enter a numeric value or enter 0 if unknown.")
782
+ else:
783
+ st.session_state["classifier_inputs"][step_id] = user_answer
784
+ st.session_state["classifier_step"] = min(step_idx+1, len(steps)-1)
785
+ st.rerun()
786
+ elif current.get("type") in ("choice","select"):
787
+ st.session_state["classifier_inputs"][step_id] = user_answer
788
+ st.session_state["classifier_step"] = min(step_idx+1, len(steps)-1)
789
+ st.rerun()
790
+ else:
791
+ # message-only step
792
+ st.session_state["classifier_step"] = min(step_idx+1, len(steps)-1)
793
+ st.rerun()
794
+ with colb:
795
+ if st.button("⬅️ Back", key=f"back_{step_id}"):
796
+ st.session_state["classifier_step"] = max(0, step_idx-1)
797
+ st.rerun()
798
+ with colsave:
799
+ if st.button("πŸ’Ύ Save & Classify now", key="save_and_classify"):
800
+ # prepare inputs in required format for classify_uscs_aashto
801
+ ci = st.session_state["classifier_inputs"].copy()
802
+ # Normalize choices into expected codes
803
+ if isinstance(ci.get("dry"), str):
804
+ ci["nDS"] = DRY_STRENGTH_MAP.get(ci.get("dry"), 5)
805
+ if isinstance(ci.get("dilat"), str):
806
+ ci["nDIL"] = DILATANCY_MAP.get(ci.get("dilat"), 6)
807
+ if isinstance(ci.get("tough"), str):
808
+ ci["nTG"] = TOUGHNESS_MAP.get(ci.get("tough"), 6)
809
+ # map 'Yes'/'No' for organic and hasD
810
+ ci["opt"] = "y" if ci.get("organic","No")=="Yes" or ci.get("organic",ci.get("organic"))=="Yes" else ci.get("organic","n")
811
+ # our field names in CI may differ: convert organic stored under 'organic' step to 'opt'
812
+ if "organic" in ci:
813
+ ci["opt"] = "y" if ci["organic"]=="Yes" else "n"
814
+ # map D entries: D60 etc may be present
815
+ # call classification
816
+ try:
817
+ res_text, aashto, GI, chars, uscs = classify_uscs_aashto(ci)
818
+ except Exception as e:
819
+ st.error(f"Classification error: {e}")
820
+ res_text = f"Error during classification: {e}"
821
+ aashto = "N/A"; GI = 0; chars = {}; uscs = "N/A"
822
+ # save into active site
823
+ site["USCS"] = uscs
824
+ site["AASHTO"] = aashto
825
+ site["GI"] = GI
826
+ site["classifier_inputs"] = ci
827
+ site["classifier_decision"] = res_text
828
+ st.success("Classification complete. Results saved to site.")
829
+ st.write("### Classification Results")
830
+ st.markdown(res_text)
831
+ # Keep classifier_step at end so user can review
832
+ st.session_state["classifier_step"] = len(steps)-1
833
+
834
+ # GSD Curve Page
835
+ def gsd_page():
836
+ st.header("πŸ“ˆ Grain Size Distribution (GSD) Curve")
837
+ site = st.session_state["sites"][st.session_state["active_site"]]
838
+ st.markdown("Enter diameters (mm) and % passing (comma-separated). Use descending diameters (largest to smallest).")
839
+ diam_input = st.text_area("Diameters (mm) comma-separated", value=site.get("GSD",{}).get("diameters","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") if site.get("GSD") else "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")
840
+ pass_input = st.text_area("% Passing comma-separated", value=site.get("GSD",{}).get("passing","100,98,96,90,85,78,72,65,55,45,35,25,18,14,8") if site.get("GSD") else "100,98,96,90,85,78,72,65,55,45,35,25,18,14,8")
841
+ if st.button("Compute GSD & Save"):
842
+ try:
843
+ diams = [float(x.strip()) for x in diam_input.split(",") if x.strip()]
844
+ passing = [float(x.strip()) for x in pass_input.split(",") if x.strip()]
845
+ metrics = compute_gsd_metrics(diams, passing)
846
+ # plot
847
+ fig, ax = plt.subplots(figsize=(7,4))
848
+ ax.semilogx(diams, passing, marker='o')
849
+ ax.set_xlabel("Particle size (mm)")
850
+ ax.set_ylabel("% Passing")
851
+ ax.invert_xaxis()
852
+ ax.grid(True, which='both', linestyle='--', linewidth=0.5)
853
+ ax.set_title("Grain Size Distribution")
854
+ st.pyplot(fig)
855
+ # save into site
856
+ site["GSD"] = {"diameters":diams, "passing":passing, **metrics}
857
+ st.success(f"Saved GSD for site. D10={metrics['D10']:.4g} mm, D30={metrics['D30']:.4g} mm, D60={metrics['D60']:.4g} mm")
858
+ except Exception as e:
859
+ st.error(f"GSD error: {e}")
860
+
861
+ # OCR Page
862
+ def ocr_page():
863
+ st.header("πŸ“· OCR β€” extract values from an image")
864
+ site = st.session_state["sites"][st.session_state["active_site"]]
865
+ if not OCR_AVAILABLE:
866
+ st.warning("OCR dependencies not available (pytesseract/PIL). Add pytesseract and pillow to requirements to enable OCR.")
867
+ uploaded = st.file_uploader("Upload an image (photo of textbook question or sieve data)", type=["png","jpg","jpeg"])
868
+ if uploaded:
869
+ if OCR_AVAILABLE:
870
+ try:
871
+ img = Image.open(uploaded)
872
+ st.image(img, caption="Uploaded", use_column_width=True)
873
+ text = pytesseract.image_to_string(img)
874
+ st.text_area("Extracted text", value=text, height=180)
875
+ # Basic parsing: try to find LL, PL, D10 etc via regex
876
+ import re
877
+ found = {}
878
+ for key in ["LL","PL","D10","D30","D60","P2","P4","CBR"]:
879
+ pattern = re.compile(rf"{key}[:=]?\s*([0-9]+\.?[0-9]*)", re.I)
880
+ m = pattern.search(text)
881
+ if m:
882
+ found[key] = float(m.group(1))
883
+ site.setdefault("classifier_inputs",{})[key] = float(m.group(1))
884
+ if found:
885
+ st.success(f"Parsed values: {found}")
886
+ st.write("Values saved into classifier inputs.")
887
+ else:
888
+ st.info("No clear numeric matches found automatically.")
889
+ except Exception as e:
890
+ st.error(f"OCR failed: {e}")
891
+ else:
892
+ st.warning("OCR not available in this deployment.")
893
+
894
+ # Locator Page (Earth Engine integration)
895
+ def locator_page():
896
+ st.header("🌍 Locator β€” Select Area of Interest")
897
+ site = st.session_state["sites"][st.session_state["active_site"]]
898
+ st.markdown("You can enter coordinates manually or draw/upload a GeoJSON boundary (draw-mode not available in this minimal example).")
899
+ lat = st.number_input("Latitude", value=site.get("lat") or 0.0, format="%.6f", key="locator_lat")
900
+ lon = st.number_input("Longitude", value=site.get("lon") or 0.0, format="%.6f", key="locator_lon")
901
+ site["lat"] = lat; site["lon"] = lon
902
+ if st.button("Fetch Earth Data (EE)"):
903
+ if not EE_AVAILABLE:
904
+ st.error("Earth Engine/Geemap not available in this environment. Please add geemap & earthengine-api to requirements.")
905
+ else:
906
+ try:
907
+ # Initialize EE if needed
908
+ if not ee.data._credentials:
909
+ # try to authenticate via service account string in env
910
+ ee_key_json = os.environ.get("EARTH_ENGINE_KEY")
911
+ # if provided as JSON string, write to temp file
912
+ tmp = tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".json")
913
+ tmp.write(ee_key_json)
914
+ tmp.flush()
915
+ ee.Initialize(service_account=os.environ.get("SERVICE_ACCOUNT"), private_key_json=tmp.name)
916
+ # sample calls (very simple placeholders)
917
+ st.info("Querying Earth Engine for available layers (this may take a few seconds)...")
918
+ # NOTE: Detailed real EE reducers and datasets must be added for production.
919
+ # We'll store placeholder results and attempt to create a simple map snapshot.
920
+ site["Soil Profile"] = f"Colluvial soils over dolomite (fetched {datetime.today().date()})"
921
+ site["Flood Data"] = "No major floods in last 20 years (placeholder)"
922
+ site["Seismic Data"] = "Seismic zone: Moderate; historic events: low"
923
+ site["Environmental Data"] = "No major environmental constraints found (placeholder)"
924
+ site["Topo Data"] = "Gentle slope"
925
+ # Create a simple map via geemap and snapshot it
926
+ m = geemap.Map(center=[lat, lon], zoom=10)
927
+ m.add_basemap("SATELLITE")
928
+ # capture map to PNG (geemap has method to export html; we use to_image if available)
929
+ try:
930
+ img = m.to_image()
931
+ buf = io.BytesIO()
932
+ img.save(buf, format="PNG")
933
+ img_bytes = buf.getvalue()
934
+ site["map_snapshot"] = img_bytes
935
+ st.image(img_bytes, caption="Map snapshot", use_column_width=True)
936
+ except Exception:
937
+ st.warning("Map snapshot not available in this environment.")
938
+ st.success("Earth Engine data fetched and saved to site (placeholders).")
939
+ except Exception as e:
940
+ st.error(f"Earth Engine error: {e}")
941
+
942
+ # GeoMate Ask (RAG) β€” simple chat with memory per site and auto-extract numeric values
943
+ def rag_page():
944
+ st.header("πŸ€– GeoMate Ask (RAG + Groq)")
945
+ site = st.session_state["sites"][st.session_state["active_site"]]
946
+ st.markdown("Chat with GeoMate. The LLM has memory per site for this session; any engineering values provided will be parsed and saved.")
947
+ if "rag_history" not in st.session_state:
948
+ st.session_state["rag_history"] = {i: [] for i in range(len(st.session_state["sites"]))}
949
+ hist = st.session_state["rag_history"].get(site["Site ID"], [])
950
+ for entry in hist:
951
+ who, text = entry.get("who"), entry.get("text")
952
+ if who == "bot":
953
+ st.markdown(f"<div class='chat-bot'>{text}</div>", unsafe_allow_html=True)
954
+ else:
955
+ st.markdown(f"<div class='chat-user'>{text}</div>", unsafe_allow_html=True)
956
+ user_msg = st.text_input("You:", key="rag_input")
957
+ if st.button("Send", key="rag_send"):
958
+ if not user_msg.strip():
959
+ st.warning("Enter a message.")
960
+ else:
961
+ # Save user msg
962
+ st.session_state["rag_history"][site["Site ID"]].append({"who":"user","text":user_msg})
963
+ # Build prompt including site context
964
+ context = {
965
+ "site": {k:v for k,v in site.items() if k in ["Site Name","lat","lon","USCS","AASHTO","GI","Load Bearing Capacity","Soil Profile","Flood Data","Seismic Data"]},
966
+ "chat_history": st.session_state["rag_history"][site["Site ID"]]
967
+ }
968
+ prompt = f"You are GeoMate AI. Site context: {json.dumps(context)}. User: {user_msg}\nRespond professionally and concisely. If user provides numeric engineering values, return them in the format: [[FIELD: value unit]]."
969
+ resp = groq_generate(prompt, model=st.session_state["llm_model"], max_tokens=400)
970
+ # Save bot reply
971
+ st.session_state["rag_history"][site["Site ID"]].append({"who":"bot","text":resp})
972
+ # Display
973
+ st.markdown(f"<div class='chat-bot'>{resp}</div>", unsafe_allow_html=True)
974
+ # Try to extract bracketed fields like [[Load bearing capacity: 2000 psf]]
975
+ import re
976
+ matches = re.findall(r"\[\[([A-Za-z0-9 _/-]+):\s*([0-9.+-eE]+)\s*([A-Za-z%\/]*)\]\]", resp)
977
+ for m in matches:
978
+ field = m[0].strip()
979
+ val = m[1].strip()
980
+ unit = m[2].strip()
981
+ # Map common fields to site keys
982
+ if "bearing" in field.lower():
983
+ site["Load Bearing Capacity"] = f"{val} {unit}"
984
+ elif "skin" in field.lower():
985
+ site["Skin Shear Strength"] = f"{val} {unit}"
986
+ elif "compaction" in field.lower():
987
+ site["Relative Compaction"] = f"{val} {unit}"
988
+ st.success("Response saved and any recognized numeric fields auto-stored in the site data.")
989
+
990
+ # Reports page β€” conversational missing-parameter bot & PDF generation
991
+ REPORT_FIELDS = [
992
+ ("Load Bearing Capacity","kPa or psf"),
993
+ ("Skin Shear Strength","kPa"),
994
+ ("Relative Compaction","%"),
995
+ ("Rate of Consolidation","mm/yr or days"),
996
+ ("Nature of Construction","text"),
997
+ ("Borehole Count","number"),
998
+ ("Max Depth (m)","m"),
999
+ ("SPT N (avg)","blows/ft"),
1000
+ ("CBR (%)","%"),
1001
+ ("Allowable Bearing (kPa)","kPa")
1002
+ ]
1003
+
1004
+ def reports_page():
1005
+ st.header("πŸ“‘ Reports β€” Classification & Full Geotechnical")
1006
+ site = st.session_state["sites"][st.session_state["active_site"]]
1007
+
1008
+ st.subheader("Classification-only report")
1009
+ if site.get("classifier_decision"):
1010
+ st.markdown("You have a saved classification for this site.")
1011
+ if st.button("Generate Classification PDF"):
1012
+ fname = f"classification_{site['Site Name'].replace(' ','_')}.pdf"
1013
+ # simple PDF
1014
+ buffer = io.BytesIO()
1015
+ doc = SimpleDocTemplate(buffer, pagesize=A4)
1016
+ elems = []
1017
+ elems.append(Paragraph("Soil Classification Report", getSampleStyleSheet()['Title']))
1018
+ elems.append(Spacer(1,6))
1019
+ elems.append(Paragraph(f"Site: {site.get('Site Name')}", getSampleStyleSheet()['Normal']))
1020
+ elems.append(Spacer(1,6))
1021
+ elems.append(Paragraph("Classification result:", getSampleStyleSheet()['Heading2']))
1022
+ elems.append(Paragraph(site.get("classifier_decision","-"), getSampleStyleSheet()['BodyText']))
1023
+ doc.build(elems)
1024
+ buffer.seek(0)
1025
+ st.download_button("Download Classification PDF", buffer, file_name=fname, mime="application/pdf")
1026
+ else:
1027
+ st.info("No classification saved for this site yet. Use the Classifier page.")
1028
+
1029
+ st.markdown("---")
1030
+ st.subheader("Full Geotechnical Report (chatbot will gather missing fields)")
1031
+ if st.button("Start Report Chatbot"):
1032
+ st.session_state["sites"][st.session_state["active_site"]]["report_convo_state"] = 0
1033
+ st.rerun()
1034
+
1035
+ # Conversational data collection
1036
+ state = site.get("report_convo_state", 0)
1037
+ if site.get("report_convo_state") is not None:
1038
+ st.markdown("Chatbot will ask for missing fields. You can answer or type 'skip' to leave blank.")
1039
+ # Show current known fields
1040
+ st.write("Current key parameters (live):")
1041
+ show_table = []
1042
+ for f,_ in REPORT_FIELDS:
1043
+ show_table.append((f, site.get(f, "Not provided")))
1044
+ st.table(show_table)
1045
+ # continue conversation step-by-step
1046
+ if state < len(REPORT_FIELDS):
1047
+ field, unit = REPORT_FIELDS[state]
1048
+ ans = st.text_input(f"GeoMate β€” Please provide '{field}' ({unit})", key=f"report_in_{state}")
1049
+ c1, c2 = st.columns([1,1])
1050
+ with c1:
1051
+ if st.button("Submit", key=f"report_submit_{state}"):
1052
+ if ans.strip().lower() in ("skip","don't know","dont know","na","n/a",""):
1053
+ site[field] = "Not provided"
1054
+ else:
1055
+ site[field] = ans.strip()
1056
+ site["report_convo_state"] = state + 1
1057
+ st.rerun()
1058
+ with c2:
1059
+ if st.button("Skip", key=f"report_skip_{state}"):
1060
+ site[field] = "Not provided"
1061
+ site["report_convo_state"] = state + 1
1062
+ st.rerun()
1063
+ else:
1064
+ st.success("All report questions asked. You can generate the full report now.")
1065
+ if st.button("Generate Full Geotechnical Report PDF"):
1066
+ # Prepare ext_refs
1067
+ ext_ref_text = st.text_area("Optional: External references (one per line)", value="")
1068
+ ext_refs = [r.strip() for r in ext_ref_text.splitlines() if r.strip()]
1069
+ # Build PDF using reportlab builder
1070
+ outname = f"Full_Geotech_Report_{site.get('Site Name','site')}.pdf"
1071
+ # include map image bytes if available
1072
+ mapimg = site.get("map_snapshot")
1073
+ build_full_geotech_pdf(site, outname, include_map_image=mapimg, ext_refs=ext_refs)
1074
+ with open(outname, "rb") as f:
1075
+ st.download_button("Download Full Geotechnical Report", f, file_name=outname, mime="application/pdf")
1076
+
1077
+ # 8) Page router
1078
+ if "page" not in st.session_state:
1079
+ st.session_state["page"] = "Home"
1080
+
1081
+ page = st.session_state["page"]
1082
+
1083
+ # Option menu top (main nav)
1084
+ selected = option_menu(
1085
+ None,
1086
+ ["Home","Classifier","GSD","OCR","Locator","RAG","Reports"],
1087
+ icons=["house","journal-code","bar-chart","camera","geo-alt","robot","file-earmark-text"],
1088
+ menu_icon="cast",
1089
+ default_index=["Home","Classifier","GSD","OCR","Locator","RAG","Reports"].index(page) if page in ["Home","Classifier","GSD","OCR","Locator","RAG","Reports"] else 0,
1090
+ orientation="horizontal",
1091
+ styles={
1092
+ "container": {"padding":"0px","background-color":"#0b0b0b"},
1093
+ "nav-link": {"font-size":"14px","color":"#cfcfcf"},
1094
+ "nav-link-selected": {"background-color":"#FF7A00","color":"white"},
1095
+ }
1096
+ )
1097
+ st.session_state["page"] = selected
1098
+ page = selected
1099
+
1100
+ # Display page content
1101
+ if page == "Home":
1102
+ landing_page()
1103
+ elif page == "Classifier":
1104
+ soil_classifier_page()
1105
+ elif page == "GSD":
1106
+ gsd_page()
1107
+ elif page == "OCR":
1108
+ ocr_page()
1109
+ elif page == "Locator":
1110
+ locator_page()
1111
+ elif page == "RAG":
1112
+ rag_page()
1113
+ elif page == "Reports":
1114
+ reports_page()
1115
+ else:
1116
+ landing_page()
1117
+
1118
+ # Footer
1119
+ st.markdown("<hr/>", unsafe_allow_html=True)
1120
+ st.markdown("<div style='text-align:center;color:#9aa7bf'>GeoMate V2 β€’ AI geotechnical copilot β€’ Built for HF Spaces</div>", unsafe_allow_html=True)