MSU576 commited on
Commit
9c1214d
·
verified ·
1 Parent(s): 101f896

Delete text2

Browse files
Files changed (1) hide show
  1. text2 +0 -1421
text2 DELETED
@@ -1,1421 +0,0 @@
1
- import os
2
- import streamlit as st
3
-
4
- # =============================
5
- # GLOBAL STATE + PERSISTENCE
6
- # =============================
7
- import streamlit as st
8
- import os
9
- import json
10
-
11
- # File to persist site data
12
- SITE_DB_FILE = "sites_db.json"
13
-
14
- # Initialize session state
15
- if "sites" not in st.session_state:
16
- # Load from disk if available
17
- if os.path.exists(SITE_DB_FILE):
18
- try:
19
- with open(SITE_DB_FILE, "r") as f:
20
- st.session_state["sites"] = json.load(f)
21
- except Exception as e:
22
- st.error(f"⚠️ Could not load sites database: {e}")
23
- st.session_state["sites"] = []
24
- else:
25
- st.session_state["sites"] = []
26
-
27
- if "active_site_idx" not in st.session_state:
28
- st.session_state["active_site_idx"] = None
29
-
30
- # =============================
31
- # SECRETS MANAGEMENT
32
- # =============================
33
- def load_secret(key: str, required: bool = True, default: str = None):
34
- """
35
- Load secret keys (Groq API, Earth Engine, etc.).
36
- 1. st.secrets
37
- 2. os.environ
38
- 3. default (if given)
39
- """
40
- value = None
41
- try:
42
- if key in st.secrets:
43
- value = st.secrets[key]
44
- elif key in os.environ:
45
- value = os.environ[key]
46
- elif default is not None:
47
- value = default
48
- elif required:
49
- st.error(f"❌ Missing required secret: {key}")
50
- except Exception as e:
51
- st.error(f"⚠️ Error loading secret `{key}`: {e}")
52
- return value
53
-
54
- # Preload secrets
55
- GROQ_API_KEY = load_secret("GROQ_API_KEY")
56
- SERVICE_ACCOUNT = load_secret("SERVICE_ACCOUNT")
57
- EARTH_ENGINE_KEY = load_secret("EARTH_ENGINE_KEY", required=False)
58
-
59
- # =============================
60
- # HELPERS
61
- # =============================
62
-
63
- def persist_sites():
64
- """Save sites to local JSON database"""
65
- try:
66
- with open(SITE_DB_FILE, "w") as f:
67
- json.dump(st.session_state["sites"], f, indent=2)
68
- except Exception as e:
69
- st.error(f"⚠️ Error saving sites: {e}")
70
-
71
- def get_active_site():
72
- # Fetch current index safely
73
- idx = st.session_state.get("active_site_idx", 0)
74
-
75
- # Ensure sites list exists
76
- sites = st.session_state.get("sites", [])
77
-
78
- # If no sites exist, create a default site
79
- if not sites:
80
- st.session_state["sites"] = [{"Site Name": "Site 1"}]
81
- st.session_state["active_site_idx"] = 0
82
- return st.session_state["sites"][0]
83
-
84
- # Ensure idx is within bounds
85
- if idx < 0 or idx >= len(sites):
86
- st.session_state["active_site_idx"] = 0
87
- idx = 0
88
-
89
- return sites[idx]
90
-
91
-
92
- def save_active_site(site_data):
93
- sites = st.session_state.get("sites", [])
94
-
95
- if not sites:
96
- st.session_state["sites"] = [site_data]
97
- st.session_state["active_site_idx"] = 0
98
- else:
99
- idx = st.session_state.get("active_site_idx", 0)
100
- if idx < 0 or idx >= len(sites):
101
- idx = 0
102
- st.session_state["active_site_idx"] = 0
103
- st.session_state["sites"][idx] = site_data
104
-
105
-
106
- def create_new_site(name: str):
107
- """Create new site with maximum soil details and set active"""
108
- new_site = {
109
- "name": name,
110
- "Soil Profile": None,
111
- "USCS Classification": None,
112
- "AASHTO Classification": None,
113
- "Soil Recognizer Confidence": None,
114
- "Region": None,
115
- "Moisture Content (%)": None,
116
- "Dry Density (kN/m³)": None,
117
- "Saturation (%)": None,
118
- "Void Ratio": None,
119
- "Porosity (%)": None,
120
- "Plastic Limit (%)": None,
121
- "Liquid Limit (%)": None,
122
- "Plasticity Index (%)": None,
123
- "Cohesion (kPa)": None,
124
- "Angle of Internal Friction (φ, degrees)": None,
125
- "Permeability (m/s)": None,
126
- "Compression Index (Cc)": None,
127
- "Recompression Index (Cr)": None,
128
- "Bearing Capacity (kN/m²)": None,
129
- "Settlement (mm)": None,
130
- "Slope Stability Factor of Safety": None,
131
- "Compaction Optimum Moisture Content (%)": None,
132
- "Compaction Maximum Dry Density (kN/m³)": None,
133
- "Seepage Analysis Notes": None,
134
- "Consolidation Notes": None,
135
- "Engineering Recommendations": [],
136
- "LLM Insights": [],
137
- "Notes": "",
138
- }
139
- st.session_state["sites"].append(new_site)
140
- st.session_state["active_site_idx"] = len(st.session_state["sites"]) - 1
141
- persist_sites()
142
- return new_site
143
-
144
- def list_sites():
145
- """Return list of all site names"""
146
- return [site["name"] for site in st.session_state["sites"]]
147
-
148
- # =============================
149
- # SIDEBAR NAVIGATION + SITE MANAGER
150
- # =============================
151
-
152
- PAGES = {
153
- "🏠 Home": "home",
154
- "🖼️ Soil Recognizer": "soil_recognizer",
155
- "📊 Soil Classifier": "soil_classifier",
156
- "🤖 RAG Chatbot": "rag_chatbot",
157
- "🗺️ Maps": "maps",
158
- "📄 PDF Export": "pdf_export",
159
- "💬 Feedback": "feedback"
160
- }
161
-
162
- def sidebar_navigation():
163
- st.sidebar.title("🌍 GeoMate Navigation")
164
-
165
- # --- SITE MANAGER ---
166
- st.sidebar.subheader("🏗️ Site Manager")
167
- sites = list_sites()
168
-
169
- if sites:
170
- selected = st.sidebar.selectbox(
171
- "Select Active Site",
172
- options=range(len(sites)),
173
- format_func=lambda i: sites[i],
174
- index=st.session_state.get("active_site_idx") or 0
175
- )
176
- if selected is not None:
177
- st.session_state["active_site_idx"] = selected
178
- site = get_active_site()
179
- st.sidebar.success(f"Active Site: {site['name']}")
180
-
181
- if st.sidebar.button("🗑️ Delete Active Site"):
182
- idx = st.session_state.get("active_site_idx")
183
- if idx is not None and idx < len(st.session_state["sites"]):
184
- deleted_name = st.session_state["sites"][idx]["name"]
185
- st.session_state["sites"].pop(idx)
186
- st.session_state["active_site_idx"] = None
187
- persist_sites()
188
- st.sidebar.warning(f"Deleted site: {deleted_name}")
189
-
190
- else:
191
- st.sidebar.info("No sites available. Create one below.")
192
-
193
- with st.sidebar.expander("➕ Create New Site"):
194
- new_name = st.text_input("Enter new site name")
195
- if st.button("Create Site"):
196
- if new_name.strip():
197
- new_site = create_new_site(new_name.strip())
198
- st.sidebar.success(f"✅ Created new site: {new_site['name']}")
199
- else:
200
- st.sidebar.error("Please enter a valid site name.")
201
-
202
- st.sidebar.markdown("---")
203
-
204
- # --- PAGE NAVIGATION ---
205
- st.sidebar.subheader("📑 Pages")
206
- page_choice = st.sidebar.radio(
207
- "Go to",
208
- list(PAGES.keys())
209
- )
210
- return PAGES[page_choice]
211
-
212
-
213
- # =============================
214
- # SITE DETAILS PANEL (Main UI)
215
- # =============================
216
- def site_details_panel():
217
- st.subheader("📋 Active Site Details")
218
- site = get_active_site()
219
- if not site:
220
- st.info("No active site selected. Please create or select one from the sidebar.")
221
- return
222
-
223
- # Editable details
224
- site["location"] = st.text_input("📍 Location", value=site.get("location", ""))
225
- site["Soil Profile"] = st.text_input("🧱 Soil Profile", value=site.get("Soil Profile", ""))
226
- site["Depth (m)"] = st.number_input("📏 Depth (m)", value=float(site.get("Depth (m)", 0.0)))
227
- site["Moisture Content (%)"] = st.number_input("💧 Moisture Content (%)", value=float(site.get("Moisture Content (%)", 0.0)))
228
- site["Dry Density (kN/m³)"] = st.number_input("🏋️ Dry Density (kN/m³)", value=float(site.get("Dry Density (kN/m³)", 0.0)))
229
- site["Liquid Limit (%)"] = st.number_input("🌊 Liquid Limit (%)", value=float(site.get("Liquid Limit (%)", 0.0)))
230
- site["Plastic Limit (%)"] = st.number_input("🌀 Plastic Limit (%)", value=float(site.get("Plastic Limit (%)", 0.0)))
231
- site["Grain Size (%)"] = st.number_input("🔬 Grain Size (%)", value=float(site.get("Grain Size (%)", 0.0)))
232
-
233
- if st.button("💾 Save Site Details"):
234
- save_active_site(site)
235
- st.success("Site details updated successfully!")
236
-
237
-
238
-
239
- # =============================
240
- # FEATURE MODULES
241
- # =============================
242
- # ----------------------------
243
- # Soil Recognizer Page (Integrated 6-Class ResNet18)
244
- # ----------------------------
245
- import torch
246
- import torch.nn as nn
247
- import torchvision.models as models
248
- import torchvision.transforms as T
249
- from PIL import Image
250
- import streamlit as st
251
-
252
- # ----------------------------
253
- # Load Soil Model (6 Classes)
254
- # ----------------------------
255
- @st.cache_resource
256
- def load_soil_model(path="soil_best_model.pth"):
257
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
258
- try:
259
- model = models.resnet18(pretrained=False)
260
- num_ftrs = model.fc.in_features
261
- model.fc = nn.Linear(num_ftrs, 6) # 6 soil classes
262
-
263
- # Load checkpoint
264
- state_dict = torch.load(path, map_location=device)
265
- model.load_state_dict(state_dict)
266
- model = model.to(device)
267
- model.eval()
268
- return model, device
269
- except Exception as e:
270
- st.error(f"⚠️ Could not load soil model: {e}")
271
- return None, device
272
-
273
- soil_model, device = load_soil_model()
274
-
275
- # ----------------------------
276
- # Soil Classes & Transform
277
- # ----------------------------
278
- SOIL_CLASSES = ["Clay", "Gravel", "Loam", "Peat", "Sand", "Silt"]
279
-
280
- transform = T.Compose([
281
- T.Resize((224, 224)),
282
- T.ToTensor(),
283
- T.Normalize([0.485, 0.456, 0.406],
284
- [0.229, 0.224, 0.225])
285
- ])
286
-
287
- # ----------------------------
288
- # Prediction Function
289
- # ----------------------------
290
- def predict_soil(img: Image.Image):
291
- if soil_model is None:
292
- return "Model not loaded", {}
293
-
294
- img = img.convert("RGB")
295
- inp = transform(img).unsqueeze(0).to(device)
296
-
297
- with torch.no_grad():
298
- logits = soil_model(inp)
299
- probs = torch.softmax(logits[0], dim=0)
300
-
301
- top_idx = torch.argmax(probs).item()
302
- predicted_class = SOIL_CLASSES[top_idx]
303
-
304
- result = {SOIL_CLASSES[i]: float(probs[i]) for i in range(len(SOIL_CLASSES))}
305
- return predicted_class, result
306
-
307
- # ----------------------------
308
- # Soil Recognizer Page
309
- # ----------------------------
310
- def soil_recognizer_page():
311
- st.header("🖼️ Soil Recognizer (ResNet18)")
312
-
313
- site = get_active_site() # your existing site getter
314
- if site is None:
315
- st.warning("⚠️ No active site selected. Please add or select a site from the sidebar.")
316
- return
317
-
318
- uploaded = st.file_uploader("Upload soil image", type=["jpg", "jpeg", "png"])
319
- if uploaded is not None:
320
- img = Image.open(uploaded)
321
- st.image(img, caption="Uploaded soil image", use_column_width=True)
322
-
323
- predicted_class, confidence_scores = predict_soil(img)
324
- st.success(f"✅ Predicted: **{predicted_class}**")
325
-
326
- st.subheader("Confidence Scores")
327
- for cls, score in confidence_scores.items():
328
- st.write(f"{cls}: {score:.2%}")
329
-
330
- if st.button("Save to site"):
331
- site["Soil Profile"] = predicted_class
332
- site["Soil Recognizer Confidence"] = confidence_scores[predicted_class]
333
- save_active_site(site)
334
- st.success("Saved prediction to active site memory.")
335
-
336
-
337
- # ----------------------------
338
- # Verbose USCS + AASHTO classifier + LLM report + PDF export
339
- # Drop this into your app.py and call soil_classifier_page() from your navigation
340
- # ----------------------------
341
- import re
342
- import io
343
- import json
344
- from math import floor
345
- from typing import Dict, Any, Tuple
346
- from PIL import Image
347
- import pytesseract
348
- import requests
349
- import streamlit as st
350
-
351
- # reportlab for PDF
352
- from reportlab.lib.pagesizes import A4
353
- from reportlab.lib.units import mm
354
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image as RLImage, PageBreak
355
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
356
- from reportlab.lib import colors
357
-
358
- # ----------------------------
359
- # Helpers to access site memory - adapt if your app uses different helpers
360
- # ----------------------------
361
- def get_active_site():
362
- idx = st.session_state.get("active_site_idx", 0)
363
- sites = st.session_state.get("sites", [])
364
- if 0 <= idx < len(sites):
365
- return sites[idx]
366
- # if none, create default
367
- if sites == []:
368
- st.session_state["sites"] = [{"Site Name": "Site 1"}]
369
- st.session_state["active_site_idx"] = 0
370
- return st.session_state["sites"][0]
371
- return None
372
-
373
- def save_active_site(site: dict):
374
- idx = st.session_state.get("active_site_idx", 0)
375
- st.session_state["sites"][idx] = site
376
- st.session_state.modified = True
377
-
378
- # ----------------------------
379
- # Utility for numeric input retrieval (flexible key names)
380
- # ----------------------------
381
- def _readf(inputs: Dict[str,Any], *keys, default: float = 0.0) -> float:
382
- for k in keys:
383
- if k in inputs and inputs[k] is not None and inputs[k] != "":
384
- try:
385
- return float(inputs[k])
386
- except Exception:
387
- try:
388
- return float(str(inputs[k]).replace("%","").strip())
389
- except Exception:
390
- pass
391
- return default
392
-
393
- # ----------------------------
394
- # AASHTO: verbatim logic from your supplied script
395
- # ----------------------------
396
- def classify_aashto_verbatim(inputs: Dict[str,Any]) -> Tuple[str, str, int, str]:
397
- """
398
- Returns (ResultCode_str, description_str, GI_int, decision_path_str)
399
- Inputs keys expected:
400
- - P200 or P2 : percent passing sieve no.200
401
- - P4 : percent passing sieve no.40 (your script uses 'P4' labelled that way)
402
- - P10 or P1 : percent passing sieve no.10 (optional)
403
- - LL, PL
404
- """
405
- P2 = _readf(inputs, "P200", "P2")
406
- P4 = _readf(inputs, "P40", "P4") # accept P40 or P4
407
- LL = _readf(inputs, "LL")
408
- PL = _readf(inputs, "PL")
409
- PI = LL - PL
410
- decision = []
411
- def note(s): decision.append(s)
412
-
413
- note(f"Input AASHTO: P2={P2}, P4={P4}, LL={LL}, PL={PL}, PI={PI}")
414
-
415
- Result = None
416
- desc = ""
417
-
418
- # Granular Materials
419
- if P2 <= 35:
420
- note("P2 <= 35% → Granular branch")
421
- if (P2 <= 15) and (P4 <= 30) and (PI <= 6):
422
- note("Condition matched: P2<=15 and P4<=30 and PI<=6 → need P10 to decide A-1-a")
423
- P1 = _readf(inputs, "P10", "P1")
424
- if P1 == 0:
425
- # Can't complete without P1; return note
426
- note("P10 not provided; cannot fully decide A-1-a. Returning tentative 'A-1-a(?)'")
427
- return "A-1-a(?)", "Candidate A-1-a (P10 missing).", 0, " -> ".join(decision)
428
- else:
429
- if P1 <= 50:
430
- Result = "A-1-a"
431
- desc = "Granular material with very good quality (A-1-a)."
432
- note("P10 <= 50 -> A-1-a")
433
- else:
434
- note("P10 > 50 -> inconsistent for A-1-a -> input check required")
435
- return "ERROR", "Inconsistent inputs for A-1-a (P10 > 50).", 0, " -> ".join(decision)
436
- elif (P2 <= 25) and (P4 <= 50) and (PI <= 6):
437
- Result = "A-1-b"
438
- desc = "Granular material (A-1-b)."
439
- note("P2 <= 25 and P4 <= 50 and PI <= 6 -> A-1-b")
440
- elif (P2 <= 35) and (P4 > 0):
441
- note("P2 <= 35 and P4 > 0 -> A-2 family branch")
442
- if LL <= 40 and PI <= 10:
443
- Result = "A-2-4"
444
- desc = "A-2-4: granular material with silt-like fines."
445
- note("LL <= 40 and PI <= 10 -> A-2-4")
446
- elif LL >= 41 and PI <= 10:
447
- Result = "A-2-5"
448
- desc = "A-2-5: granular with higher LL fines."
449
- note("LL >= 41 and PI <= 10 -> A-2-5")
450
- elif LL <= 40 and PI >= 11:
451
- Result = "A-2-6"
452
- desc = "A-2-6: granular with clay-like fines."
453
- note("LL <= 40 and PI >= 11 -> A-2-6")
454
- elif LL >= 41 and PI >= 11:
455
- Result = "A-2-7"
456
- desc = "A-2-7: granular with high plasticity fines."
457
- note("LL >= 41 and PI >= 11 -> A-2-7")
458
- else:
459
- Result = "A-2-?"
460
- desc = "A-2 family ambiguous - needs more data."
461
- note("A-2 branch ambigous.")
462
- else:
463
- Result = "A-3"
464
- desc = "A-3: clean sand."
465
- note("Else -> A-3 (clean sands)")
466
- else:
467
- # Silt-Clay Materials
468
- note("P2 > 35% -> Fine (silt/clay) branch")
469
- if LL <= 40 and PI <= 10:
470
- Result = "A-4"
471
- desc = "A-4: silt of low LL/PI."
472
- note("LL <= 40 and PI <= 10 -> A-4")
473
- elif LL >= 41 and PI <= 10:
474
- Result = "A-5"
475
- desc = "A-5: elastic silt (higher LL but low PI)."
476
- note("LL >= 41 and PI <= 10 -> A-5")
477
- elif LL <= 40 and PI >= 11:
478
- Result = "A-6"
479
- desc = "A-6: clay of low LL and higher PI."
480
- note("LL <= 40 and PI >= 11 -> A-6")
481
- else:
482
- # final A-7 determination
483
- if PI <= (LL - 30):
484
- Result = "A-7-5"
485
- desc = "A-7-5: clay of intermediate plasticity."
486
- note("PI <= (LL - 30) -> A-7-5")
487
- elif PI > (LL - 30):
488
- Result = "A-7-6"
489
- desc = "A-7-6: clay of relatively higher plasticity."
490
- note("PI > (LL - 30) -> A-7-6")
491
- else:
492
- Result = "ERROR"
493
- desc = "Ambiguous A-7 branch."
494
- note("AASHTO A-7 branch ambiguous")
495
-
496
- # --- Group Index (GI) calculation verbatim from your snippet ---
497
- a = P2 - 35
498
- if a <= 40 and a >= 0:
499
- a_val = a
500
- elif a < 0:
501
- a_val = 0
502
- else:
503
- a_val = 40
504
-
505
- b = P2 - 15
506
- if b <= 40 and b >= 0:
507
- b_val = b
508
- elif b < 0:
509
- b_val = 0
510
- else:
511
- b_val = 40
512
-
513
- c = LL - 40
514
- if c <= 20 and c >= 0:
515
- c_val = c
516
- elif c < 0:
517
- c_val = 0
518
- else:
519
- c_val = 20
520
-
521
- d = PI - 10
522
- if d <= 20 and d >= 0:
523
- d_val = d
524
- elif d < 0:
525
- d_val = 0
526
- else:
527
- d_val = 20
528
-
529
- GI = floor(0.2 * a_val + 0.005 * a_val * c_val + 0.01 * b_val * d_val)
530
- note(f"GI compute -> a={a_val}, b={b_val}, c={c_val}, d={d_val}, GI={GI}")
531
-
532
- decision_path = " -> ".join(decision)
533
- full_code = f"{Result} ({GI})" if Result not in [None, "ERROR", "A-1-a(?)"] else (Result if Result != "A-1-a(?)" else "A-1-a (?)")
534
- return full_code, desc, GI, decision_path
535
-
536
- # ----------------------------
537
- # USCS: verbatim logic from your supplied script
538
- # ----------------------------
539
- def classify_uscs_verbatim(inputs: Dict[str,Any]) -> Tuple[str, str, str]:
540
- """
541
- Returns (USCS_code_str, description_str, decision_path_str)
542
- Accepts inputs:
543
- - organic (bool or 'y'/'n')
544
- - P200 / P2 percent passing #200
545
- - P4 : percent passing sieve no.4 (4.75 mm)
546
- - D60, D30, D10 (mm)
547
- - LL, PL
548
- - nDS, nDIL, nTG options for fines behaviour (integers)
549
- Implementation follows your original code's branches exactly.
550
- """
551
- decision = []
552
- def note(s): decision.append(s)
553
-
554
- organic = inputs.get("organic", False)
555
- if isinstance(organic, str):
556
- organic = organic.lower() in ("y","yes","true","1")
557
-
558
- if organic:
559
- note("Organic content indicated -> Pt")
560
- return "Pt", "Peat / Organic soil — compressible, poor engineering properties.", "Organic branch: Pt"
561
-
562
- P2 = _readf(inputs, "P200", "P2")
563
- note(f"P200 = {P2}%")
564
-
565
- if P2 <= 50:
566
- # Coarse-grained soils
567
- P4 = _readf(inputs, "P4", "P4_sieve", "P40")
568
- note(f"% passing #4 (P4) = {P4}%")
569
- op = inputs.get("d_values_provided", None)
570
- D60 = _readf(inputs, "D60")
571
- D30 = _readf(inputs, "D30")
572
- D10 = _readf(inputs, "D10")
573
- if D60 != 0 and D30 != 0 and D10 != 0:
574
- Cu = (D60 / D10) if D10 != 0 else 0
575
- Cc = ((D30 ** 2) / (D10 * D60)) if (D10 * D60) != 0 else 0
576
- note(f"D-values present -> D60={D60}, D30={D30}, D10={D10}, Cu={Cu}, Cc={Cc}")
577
- else:
578
- Cu = 0
579
- Cc = 0
580
- note("D-values missing or incomplete -> using Atterberg/fines-based branches")
581
-
582
- LL = _readf(inputs, "LL")
583
- PL = _readf(inputs, "PL")
584
- PI = LL - PL
585
- note(f"LL={LL}, PL={PL}, PI={PI}")
586
-
587
- # Gravels
588
- if P4 <= 50:
589
- note("P4 <= 50 -> Gravel family")
590
- if (Cu != 0) and (Cc != 0):
591
- if (Cu >= 4) and (1 <= Cc <= 3):
592
- note("Cu >=4 and 1<=Cc<=3 -> GW")
593
- return "GW", "Well-graded gravel with excellent load-bearing capacity.", "GW via Cu/Cc"
594
- elif not ((Cu < 4) and (1 <= Cc <= 3)):
595
- note("Cu <4 or Cc out of 1..3 -> GP")
596
- return "GP", "Poorly-graded gravel.", "GP via Cu/Cc"
597
- else:
598
- # no D-values: use fines/PI checks
599
- if (PI < 4) or (PI < 0.73 * (LL - 20)):
600
- note("PI < 4 or PI < 0.73*(LL-20) -> GM")
601
- return "GM", "Silty gravel with moderate properties.", "GM via fines"
602
- elif (PI > 7) and (PI > 0.73 * (LL - 20)):
603
- note("PI > 7 and PI > 0.73*(LL-20) -> GC")
604
- return "GC", "Clayey gravel — reduced drainage.", "GC via fines"
605
- else:
606
- note("Intermediate fines -> GM-GC")
607
- return "GM-GC", "Mixed silt/clay in gravel — variable.", "GM-GC via fines"
608
- else:
609
- # Sands path
610
- note("P4 > 50 -> Sand family")
611
- if (Cu != 0) and (Cc != 0):
612
- if (Cu >= 6) and (1 <= Cc <= 3):
613
- note("Cu >= 6 and 1 <= Cc <= 3 -> SW")
614
- return "SW", "Well-graded sand with good engineering behavior.", "SW via Cu/Cc"
615
- elif not ((Cu < 6) and (1 <= Cc <= 3)):
616
- note("Cu <6 or Cc out of 1..3 -> SP")
617
- return "SP", "Poorly-graded sand.", "SP via Cu/Cc"
618
- else:
619
- if (PI < 4) or (PI <= 0.73 * (LL - 20)):
620
- note("PI < 4 or PI <= 0.73*(LL-20) -> SM")
621
- return "SM", "Silty sand — moderate engineering quality.", "SM via fines"
622
- elif (PI > 7) and (PI > 0.73 * (LL - 20)):
623
- note("PI > 7 and PI > 0.73*(LL-20) -> SC")
624
- return "SC", "Clayey sand — reduced permeability and strength.", "SC via fines"
625
- else:
626
- note("Intermediate -> SM-SC")
627
- return "SM-SC", "Sand mixed with fines (silt/clay).", "SM-SC via fines"
628
- else:
629
- # Fine-grained soils
630
- note("P200 > 50 -> Fine-grained path")
631
- LL = _readf(inputs, "LL")
632
- PL = _readf(inputs, "PL")
633
- PI = LL - PL
634
- note(f"LL={LL}, PL={PL}, PI={PI}")
635
-
636
- # Read behaviour options
637
- nDS = int(_readf(inputs, "nDS", default=0))
638
- nDIL = int(_readf(inputs, "nDIL", default=0))
639
- nTG = int(_readf(inputs, "nTG", default=0))
640
- note(f"Behavior options (nDS,nDIL,nTG) = ({nDS},{nDIL},{nTG})")
641
-
642
- # Low plasticity fines
643
- if LL < 50:
644
- note("LL < 50 -> low plasticity branch")
645
- if (20 <= LL < 50) and (PI <= 0.73 * (LL - 20)):
646
- note("20 <= LL < 50 and PI <= 0.73*(LL-20)")
647
- if (nDS == 1) or (nDIL == 3) or (nTG == 3):
648
- note("-> ML")
649
- return "ML", "Silt of low plasticity.", "ML via LL/PI/observations"
650
- elif (nDS == 3) or (nDIL == 3) or (nTG == 3):
651
- note("-> OL (organic silt)")
652
- return "OL", "Organic silt — compressible.", "OL via observations"
653
- else:
654
- note("-> ML-OL (ambiguous)")
655
- return "ML-OL", "Mixed silt/organic.", "ML-OL via ambiguity"
656
- elif (10 <= LL <= 30) and (4 <= PI <= 7) and (PI > 0.72 * (LL - 20)):
657
- note("10 <= LL <=30 and 4<=PI<=7 and PI > 0.72*(LL-20)")
658
- if (nDS == 1) or (nDIL == 1) or (nTG == 1):
659
- note("-> ML")
660
- return "ML", "Low plasticity silt", "ML via specific conditions"
661
- elif (nDS == 2) or (nDIL == 2) or (nTG == 2):
662
- note("-> CL")
663
- return "CL", "Low plasticity clay", "CL via specific conditions"
664
- else:
665
- note("-> ML-CL (ambiguous)")
666
- return "ML-CL", "Mixed ML/CL", "ML-CL via ambiguity"
667
- else:
668
- note("Default low-plasticity branch -> CL")
669
- return "CL", "Low plasticity clay", "CL default"
670
- else:
671
- # High plasticity fines
672
- note("LL >= 50 -> high plasticity branch")
673
- if PI < 0.73 * (LL - 20):
674
- note("PI < 0.73*(LL-20)")
675
- if (nDS == 3) or (nDIL == 4) or (nTG == 4):
676
- note("-> MH")
677
- return "MH", "Elastic silt (high LL)", "MH via observations"
678
- elif (nDS == 2) or (nDIL == 2) or (nTG == 4):
679
- note("-> OH")
680
- return "OH", "Organic high plasticity silt/clay", "OH via observations"
681
- else:
682
- note("-> MH-OH (ambiguous)")
683
- return "MH-OH", "Mixed MH/OH", "MH-OH via ambiguity"
684
- else:
685
- note("PI >= 0.73*(LL-20) -> CH")
686
- return "CH", "High plasticity clay — compressible, problematic for foundations.", "CH default high-PL"
687
-
688
- note("Fell through branches -> UNCLASSIFIED")
689
- return "UNCLASSIFIED", "Insufficient data for USCS classification.", "No valid decision path"
690
-
691
- # ----------------------------
692
- # Engineering descriptors & LaTeX-table mapping
693
- # ----------------------------
694
- ENGINEERING_TABLE = {
695
- "Gravel": {
696
- "Settlement": "None",
697
- "Quicksand": "Impossible",
698
- "Frost": "None",
699
- "Groundwater lowering": "Possible",
700
- "Cement grouting": "Possible",
701
- "Silicate/bitumen": "Unsuitable",
702
- "Compressed air": "Possible (loss of air, slow progress)"
703
- },
704
- "Coarse sand": {
705
- "Settlement": "None",
706
- "Quicksand": "Impossible",
707
- "Frost": "None",
708
- "Groundwater lowering": "Suitable",
709
- "Cement grouting": "Possible only if very coarse",
710
- "Silicate/bitumen": "Suitable",
711
- "Compressed air": "Suitable"
712
- },
713
- "Medium sand": {
714
- "Settlement": "None",
715
- "Quicksand": "Unlikely",
716
- "Frost": "None",
717
- "Groundwater lowering": "Suitable",
718
- "Cement grouting": "Impossible",
719
- "Silicate/bitumen": "Suitable",
720
- "Compressed air": "Suitable"
721
- },
722
- "Fine sand": {
723
- "Settlement": "None",
724
- "Quicksand": "Liable",
725
- "Frost": "None",
726
- "Groundwater lowering": "Suitable",
727
- "Cement grouting": "Impossible",
728
- "Silicate/bitumen": "Not possible in very fine sands",
729
- "Compressed air": "Suitable"
730
- },
731
- "Silt": {
732
- "Settlement": "Occurs",
733
- "Quicksand": "Liable (coarse silts / silty sands)",
734
- "Frost": "Occurs",
735
- "Groundwater lowering": "Impossible (except electro-osmosis)",
736
- "Cement grouting": "Impossible",
737
- "Silicate/bitumen": "Impossible",
738
- "Compressed air": "Suitable"
739
- },
740
- "Clay": {
741
- "Settlement": "Occurs",
742
- "Quicksand": "Impossible",
743
- "Frost": "None",
744
- "Groundwater lowering": "Impossible",
745
- "Cement grouting": "Only in stiff, fissured clay",
746
- "Silicate/bitumen": "Impossible",
747
- "Compressed air": "Used for support only (Glossop & Skempton)"
748
- }
749
- }
750
-
751
- def engineering_characteristics_from_uscs(uscs_code: str) -> Dict[str,str]:
752
- # map family codes to table entries
753
- if uscs_code.startswith("G"):
754
- return ENGINEERING_TABLE["Gravel"]
755
- if uscs_code.startswith("S"):
756
- # differentiate coarse/medium/fine sand? We'll return Medium sand baseline
757
- return ENGINEERING_TABLE["Medium sand"]
758
- if uscs_code in ("ML","MH","OL","OH"):
759
- return ENGINEERING_TABLE["Silt"]
760
- if uscs_code.startswith("C") or uscs_code == "CL" or uscs_code == "CH":
761
- return ENGINEERING_TABLE["Clay"]
762
- # default
763
- return {"Settlement":"Varies", "Quicksand":"Varies", "Frost":"Varies"}
764
-
765
- # ----------------------------
766
- # Combined classifier that produces a rich result
767
- # ----------------------------
768
- def classify_all(inputs: Dict[str,Any]) -> Dict[str,Any]:
769
- """
770
- Run both AASHTO & USCS verbatim logic and return a dictionary with:
771
- - AASHTO_code, AASHTO_desc, GI, AASHTO_decision_path
772
- - USCS_code, USCS_desc, USCS_decision_path
773
- - engineering_characteristics (dict)
774
- - engineering_summary (short deterministic summary)
775
- """
776
- aashto_code, aashto_desc, GI, aashto_path = classify_aashto_verbatim(inputs)
777
- uscs_code, uscs_desc, uscs_path = classify_uscs_verbatim(inputs)
778
-
779
- eng_chars = engineering_characteristics_from_uscs(uscs_code)
780
-
781
- # Deterministic engineering summary
782
- summary_lines = []
783
- summary_lines.append(f"USCS: {uscs_code} — {uscs_desc}")
784
- summary_lines.append(f"AASHTO: {aashto_code} — {aashto_desc}")
785
- summary_lines.append(f"Group Index: {GI}")
786
- # family derived remarks
787
- if uscs_code.startswith("C") or uscs_code in ("CH","CL"):
788
- summary_lines.append("Clayey behavior: expect significant compressibility, low permeability, potential long-term settlement — advisable to assess consolidation & use deep foundations for heavy loads.")
789
- elif uscs_code.startswith("G") or uscs_code.startswith("S"):
790
- summary_lines.append("Granular behavior: good drainage and bearing; suitable for shallow foundations/pavements when properly compacted.")
791
- elif uscs_code in ("ML","MH","OL","OH"):
792
- summary_lines.append("Silty/organic behavior: moderate-to-high compressibility; frost-susceptible; avoid as direct support for heavy structures without treatment.")
793
- else:
794
- summary_lines.append("Mixed or unclear behavior; recommend targeted lab testing and conservative design assumptions.")
795
-
796
- out = {
797
- "AASHTO_code": aashto_code,
798
- "AASHTO_description": aashto_desc,
799
- "GI": GI,
800
- "AASHTO_decision_path": aashto_path,
801
- "USCS_code": uscs_code,
802
- "USCS_description": uscs_desc,
803
- "USCS_decision_path": uscs_path,
804
- "engineering_characteristics": eng_chars,
805
- "engineering_summary": "\n".join(summary_lines)
806
- }
807
- return out
808
-
809
- # ----------------------------
810
- # LLM integration (Groq) to produce a rich humanized report
811
- # ----------------------------
812
- def call_groq_for_explanation(prompt: str, model_name: str = "meta-llama/llama-4-maverick-17b-128e-instruct", max_tokens: int = 800) -> str:
813
- """
814
- Use Groq client via REST if GROQ_API_KEY in st.secrets
815
- (Note: adapt to your Groq client wrapper if you have it)
816
- """
817
- key = None
818
- # check st.secrets first
819
- if "GROQ_API_KEY" in st.secrets:
820
- key = st.secrets["GROQ_API_KEY"]
821
- else:
822
- key = st.session_state.get("GROQ_API_KEY") or None
823
-
824
- if not key:
825
- return "Groq API key not found. LLM humanized explanation not available."
826
-
827
- url = "https://api.groq.com/v1/chat/completions"
828
- headers = {"Authorization": f"Bearer {key}", "Content-Type":"application/json"}
829
- payload = {
830
- "model": model_name,
831
- "messages": [
832
- {"role":"system","content":"You are GeoMate, a professional geotechnical engineering assistant."},
833
- {"role":"user","content": prompt}
834
- ],
835
- "temperature": 0.2,
836
- "max_tokens": max_tokens
837
- }
838
- try:
839
- resp = requests.post(url, headers=headers, json=payload, timeout=60)
840
- resp.raise_for_status()
841
- data = resp.json()
842
- # try to extract content defensively
843
- if "choices" in data and len(data["choices"])>0:
844
- content = data["choices"][0].get("message", {}).get("content") or data["choices"][0].get("text") or str(data["choices"][0])
845
- return content
846
- return json.dumps(data)
847
- except Exception as e:
848
- return f"LLM call failed: {e}"
849
-
850
- # ----------------------------
851
- # Build PDF bytes for classification report
852
- # ----------------------------
853
- def build_classification_pdf_bytes(site: Dict[str,Any], classification: Dict[str,Any], explanation_text: str) -> bytes:
854
- buf = io.BytesIO()
855
- doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=18*mm, rightMargin=18*mm, topMargin=18*mm, bottomMargin=18*mm)
856
- styles = getSampleStyleSheet()
857
- title_style = ParagraphStyle("title", parent=styles["Title"], fontSize=18, textColor=colors.HexColor("#FF6600"), alignment=1)
858
- h1 = ParagraphStyle("h1", parent=styles["Heading1"], fontSize=12, textColor=colors.HexColor("#FF6600"))
859
- body = ParagraphStyle("body", parent=styles["BodyText"], fontSize=10)
860
-
861
- elems = []
862
- elems.append(Paragraph("GeoMate V2 — Classification Report", title_style))
863
- elems.append(Spacer(1,6))
864
- elems.append(Paragraph(f"Site: {site.get('Site Name','Unnamed')}", h1))
865
- elems.append(Paragraph(f"Date: {st.datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}", body))
866
- elems.append(Spacer(1,8))
867
-
868
- # Inputs summary
869
- elems.append(Paragraph("Laboratory Inputs", h1))
870
- inputs = site.get("classifier_inputs", {})
871
- if inputs:
872
- data = [["Parameter","Value"]]
873
- for k,v in inputs.items():
874
- data.append([str(k), str(v)])
875
- table = Table(data, colWidths=[80*mm, 80*mm])
876
- table.setStyle(TableStyle([("GRID",(0,0),(-1,-1),0.5,colors.grey), ("BACKGROUND",(0,0),(-1,0),colors.HexColor("#FF6600")), ("TEXTCOLOR",(0,0),(-1,0),colors.white)]))
877
- elems.append(table)
878
- else:
879
- elems.append(Paragraph("No lab inputs recorded.", body))
880
- elems.append(Spacer(1,8))
881
-
882
- # Deterministic results
883
- elems.append(Paragraph("Deterministic Classification Results", h1))
884
- elems.append(Paragraph(f"USCS: {classification.get('USCS_code','N/A')} — {classification.get('USCS_description','')}", body))
885
- elems.append(Paragraph(f"AASHTO: {classification.get('AASHTO_code','N/A')} — {classification.get('AASHTO_description','')}", body))
886
- elems.append(Paragraph(f"Group Index: {classification.get('GI','N/A')}", body))
887
- elems.append(Spacer(1,6))
888
- elems.append(Paragraph("USCS decision path (verbatim):", h1))
889
- elems.append(Paragraph(classification.get("USCS_decision_path","Not recorded"), body))
890
- elems.append(Spacer(1,6))
891
- elems.append(Paragraph("AASHTO decision path (verbatim):", h1))
892
- elems.append(Paragraph(classification.get("AASHTO_decision_path","Not recorded"), body))
893
- elems.append(Spacer(1,8))
894
-
895
- # Engineering characteristics table
896
- elems.append(Paragraph("Engineering Characteristics (from reference table)", h1))
897
- eng = classification.get("engineering_characteristics", {})
898
- if eng:
899
- eng_data = [["Property","Value"]]
900
- for k,v in eng.items():
901
- eng_data.append([k, v])
902
- t2 = Table(eng_data, colWidths=[60*mm, 100*mm])
903
- t2.setStyle(TableStyle([("GRID",(0,0),(-1,-1),0.5,colors.grey), ("BACKGROUND",(0,0),(-1,0),colors.HexColor("#FF6600")), ("TEXTCOLOR",(0,0),(-1,0),colors.white)]))
904
- elems.append(t2)
905
- elems.append(Spacer(1,8))
906
-
907
- # LLM Explanation (humanized)
908
- elems.append(Paragraph("Humanized Engineering Explanation (LLM)", h1))
909
- if explanation_text:
910
- # avoid overly long text blocks; split into paragraphs
911
- for para in explanation_text.strip().split("\n\n"):
912
- elems.append(Paragraph(para.strip().replace("\n"," "), body))
913
- elems.append(Spacer(1,4))
914
- else:
915
- elems.append(Paragraph("No LLM explanation available.", body))
916
-
917
- # Map snapshot (optional)
918
- if "map_snapshot" in site and site["map_snapshot"]:
919
- snap = site["map_snapshot"]
920
- # If snapshot is HTML, skip embedding; if it's an image path, include it.
921
- if isinstance(snap, str) and snap.lower().endswith((".png",".jpg",".jpeg")) and os.path.exists(snap):
922
- elems.append(PageBreak())
923
- elems.append(Paragraph("Map Snapshot", h1))
924
- elems.append(RLImage(snap, width=160*mm, height=90*mm))
925
-
926
- doc.build(elems)
927
- pdf_bytes = buf.getvalue()
928
- buf.close()
929
- return pdf_bytes
930
-
931
- # ----------------------------
932
- # Streamlit Chat-style Soil Classifier Page
933
- # ----------------------------
934
- def soil_classifier_page():
935
- st.header("🧭 Soil Classifier — USCS & AASHTO (Verbatim)")
936
-
937
- site = get_active_site()
938
- if site is None:
939
- st.warning("No active site. Add a site first in the sidebar.")
940
- return
941
-
942
- # Ensure classifier_inputs exists
943
- site.setdefault("classifier_inputs", {})
944
-
945
- col1, col2 = st.columns([2,1])
946
- with col1:
947
- st.markdown("**Upload lab sheet (image) for OCR** — the extracted values will auto-fill classifier inputs.")
948
- uploaded = st.file_uploader("Upload image (png/jpg)", type=["png","jpg","jpeg"], key="clf_ocr_upload")
949
- if uploaded:
950
- img = Image.open(uploaded)
951
- st.image(img, caption="Uploaded lab sheet (OCR)", use_column_width=True)
952
- try:
953
- raw_text = pytesseract.image_to_string(img)
954
- st.text_area("OCR raw text (preview)", raw_text, height=180)
955
- # Basic numeric extraction heuristics (LL, PL, P200, P4, D60/D30/D10)
956
- # Try many patterns for robustness
957
- def find_first(pattern):
958
- m = re.search(pattern, raw_text, re.IGNORECASE)
959
- return float(m.group(1)) if m else None
960
-
961
- possible = {}
962
- for pat_key, pats in {
963
- "LL": [r"LL\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)", r"Liquid\s*Limit\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
964
- "PL": [r"PL\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)", r"Plastic\s*Limit\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
965
- "P200":[r"%\s*Passing\s*#?200\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)", r"P200\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)", r"Passing\s*0\.075\s*mm\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
966
- "P4":[r"%\s*Passing\s*#?4\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)", r"P4\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
967
- "D60":[r"D60\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)", r"D_{60}\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
968
- "D30":[r"D30\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
969
- "D10":[r"D10\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"]
970
- }.items():
971
- for p in pats:
972
- v = find_first(p)
973
- if v is not None:
974
- possible[pat_key] = v
975
- break
976
- # copy found to site inputs
977
- for k,v in possible.items():
978
- site["classifier_inputs"][k] = v
979
- save_active_site(site)
980
- st.success(f"OCR auto-filled: {', '.join([f'{k}={v}' for k,v in possible.items()])}")
981
- except Exception as e:
982
- st.error(f"OCR parsing failed: {e}")
983
-
984
- st.markdown("**Or type soil parameters / paste lab line** (e.g. `LL=45 PL=22 P200=58 P4=12 D60=1.2 D30=0.45 D10=0.08`) — chat-style input below.")
985
- user_text = st.text_area("Enter parameters or notes", value="", key="clf_text_input", height=120)
986
-
987
- if st.button("Run Classification"):
988
- # parse user_text for numbers too (merge with site inputs)
989
- txt = user_text or ""
990
- # find key=value pairs
991
- kvs = dict(re.findall(r"([A-Za-z0-9_%]+)\s*[=:\-]\s*([0-9]+(?:\.[0-9]+)?)", txt))
992
- # normalize keys
993
- norm = {}
994
- for k,v in kvs.items():
995
- klow = k.strip().lower()
996
- if klow in ("ll","liquidlimit","liquid_limit","liquid"):
997
- norm["LL"] = float(v)
998
- elif klow in ("pl","plasticlimit","plastic_limit","plastic"):
999
- norm["PL"] = float(v)
1000
- elif klow in ("pi","plasticityindex"):
1001
- norm["PI"] = float(v)
1002
- elif klow in ("p200","%200","p_200","passing200"):
1003
- norm["P200"] = float(v)
1004
- elif klow in ("p4","p_4","passing4"):
1005
- norm["P4"] = float(v)
1006
- elif klow in ("d60","d_60"):
1007
- norm["D60"] = float(v)
1008
- elif klow in ("d30","d_30"):
1009
- norm["D30"] = float(v)
1010
- elif klow in ("d10","d_10"):
1011
- norm["D10"] = float(v)
1012
- # merge into site inputs
1013
- site["classifier_inputs"].update(norm)
1014
- save_active_site(site)
1015
-
1016
- # run verbatim classifiers
1017
- inputs_for_class = site["classifier_inputs"]
1018
- # ensure keys exist (coerce to numeric defaults)
1019
- result = classify_all(inputs_for_class)
1020
- # store result into site memory
1021
- site["classification_report"] = result
1022
- save_active_site(site)
1023
-
1024
- st.success("Deterministic classification complete.")
1025
- st.markdown("**USCS result:** " + str(result.get("USCS_code")))
1026
- st.markdown("**AASHTO result:** " + str(result.get("AASHTO_code")) + f" (GI={result.get('GI')})")
1027
- st.markdown("**Engineering summary (deterministic):**")
1028
- st.info(result.get("engineering_summary"))
1029
-
1030
- # call LLM to produce a humanized expanded report (if GROQ key exists)
1031
- prompt = f"""
1032
- You are GeoMate, a professional geotechnical engineer assistant.
1033
- Given the following laboratory inputs and deterministic classification, produce a clear, technical
1034
- and human-friendly classification report, explaining what the soil is, how it behaves, engineering
1035
- implications (bearing, settlement, stiffness), suitability for shallow foundations and road subgrades,
1036
- and practical recommendations for site engineering.
1037
-
1038
- Site: {site.get('Site Name','Unnamed')}
1039
- Inputs (as parsed): {json.dumps(site.get('classifier_inputs',{}), indent=2)}
1040
- Deterministic classification results:
1041
- USCS: {result.get('USCS_code')}
1042
- USCS decision path: {result.get('USCS_decision_path')}
1043
- AASHTO: {result.get('AASHTO_code')}
1044
- AASHTO decision path: {result.get('AASHTO_decision_path')}
1045
- Group Index: {result.get('GI')}
1046
- Engineering characteristics reference table: {json.dumps(result.get('engineering_characteristics',{}), indent=2)}
1047
-
1048
- Provide:
1049
- - Executive summary (3-5 sentences)
1050
- - Engineering interpretation (detailed)
1051
- - Specific recommendations (foundations, drainage, compaction, stabilization)
1052
- - Short checklist of items for further testing.
1053
- """
1054
- st.info("Generating humanized report via LLM (Groq) — this may take a few seconds.")
1055
- explanation = call_groq_for_explanation(prompt)
1056
- # fallback if failed
1057
- if explanation.startswith("LLM call failed") or explanation.startswith("Groq API key not found"):
1058
- # build local humanized explanation deterministically
1059
- explanation = ("Humanized explanation not available via LLM. "
1060
- "Deterministic summary: \n\n" + result.get("engineering_summary", "No summary."))
1061
-
1062
- # save explanation to site memory
1063
- site.setdefault("reports", {})
1064
- site["reports"]["last_classification_explanation"] = explanation
1065
- save_active_site(site)
1066
-
1067
- st.markdown("**Humanized Explanation (LLM or fallback):**")
1068
- st.write(explanation)
1069
-
1070
- # Build PDF bytes and offer download
1071
- pdf_bytes = build_classification_pdf_bytes(site, result, explanation)
1072
- st.download_button("Download Classification PDF", data=pdf_bytes, file_name=f"classification_{site.get('Site Name','site')}.pdf", mime="application/pdf")
1073
-
1074
- # side column shows current parsed inputs / last results
1075
- with col2:
1076
- st.markdown("**Current parsed inputs**")
1077
- st.json(site.get("classifier_inputs", {}))
1078
- st.markdown("**Last deterministic classification (if any)**")
1079
- st.json(site.get("classification_report", {}))
1080
-
1081
- # End of snippet
1082
-
1083
- # ----------------------------
1084
- # LLM integration (Groq) to produce a rich humanized report
1085
- # ----------------------------
1086
- def call_groq_for_explanation(prompt: str, model_name: str = "meta-llama/llama-4-maverick-17b-128e-instruct", max_tokens: int = 800) -> str:
1087
- """
1088
- Use Groq client via REST if GROQ_API_KEY in st.secrets
1089
- (Note: adapt to your Groq client wrapper if you have it)
1090
- """
1091
- key = None
1092
- # check st.secrets first
1093
- if "GROQ_API_KEY" in st.secrets:
1094
- key = st.secrets["GROQ_API_KEY"]
1095
- else:
1096
- key = st.session_state.get("GROQ_API_KEY") or None
1097
-
1098
- if not key:
1099
- return "Groq API key not found. LLM humanized explanation not available."
1100
-
1101
- url = "https://api.groq.com/v1/chat/completions"
1102
- headers = {"Authorization": f"Bearer {key}", "Content-Type":"application/json"}
1103
- payload = {
1104
- "model": model_name,
1105
- "messages": [
1106
- {"role":"system","content":"You are GeoMate, a professional geotechnical engineering assistant."},
1107
- {"role":"user","content": prompt}
1108
- ],
1109
- "temperature": 0.2,
1110
- "max_tokens": max_tokens
1111
- }
1112
- try:
1113
- resp = requests.post(url, headers=headers, json=payload, timeout=60)
1114
- resp.raise_for_status()
1115
- data = resp.json()
1116
- # try to extract content defensively
1117
- if "choices" in data and len(data["choices"])>0:
1118
- content = data["choices"][0].get("message", {}).get("content") or data["choices"][0].get("text") or str(data["choices"][0])
1119
- return content
1120
- return json.dumps(data)
1121
- except Exception as e:
1122
- return f"LLM call failed: {e}"
1123
-
1124
- # ----------------------------
1125
- # Build PDF bytes for classification report
1126
- # ----------------------------
1127
- def build_classification_pdf_bytes(site: Dict[str,Any], classification: Dict[str,Any], explanation_text: str) -> bytes:
1128
- buf = io.BytesIO()
1129
- doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=18*mm, rightMargin=18*mm, topMargin=18*mm, bottomMargin=18*mm)
1130
- styles = getSampleStyleSheet()
1131
- title_style = ParagraphStyle("title", parent=styles["Title"], fontSize=18, textColor=colors.HexColor("#FF6600"), alignment=1)
1132
- h1 = ParagraphStyle("h1", parent=styles["Heading1"], fontSize=12, textColor=colors.HexColor("#FF6600"))
1133
- body = ParagraphStyle("body", parent=styles["BodyText"], fontSize=10)
1134
-
1135
- elems = []
1136
- elems.append(Paragraph("GeoMate V2 — Classification Report", title_style))
1137
- elems.append(Spacer(1,6))
1138
- elems.append(Paragraph(f"Site: {site.get('Site Name','Unnamed')}", h1))
1139
- elems.append(Paragraph(f"Date: {st.datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}", body))
1140
- elems.append(Spacer(1,8))
1141
-
1142
- # Inputs summary
1143
- elems.append(Paragraph("Laboratory Inputs", h1))
1144
- inputs = site.get("classifier_inputs", {})
1145
- if inputs:
1146
- data = [["Parameter","Value"]]
1147
- for k,v in inputs.items():
1148
- data.append([str(k), str(v)])
1149
- table = Table(data, colWidths=[80*mm, 80*mm])
1150
- table.setStyle(TableStyle([("GRID",(0,0),(-1,-1),0.5,colors.grey), ("BACKGROUND",(0,0),(-1,0),colors.HexColor("#FF6600")), ("TEXTCOLOR",(0,0),(-1,0),colors.white)]))
1151
- elems.append(table)
1152
- else:
1153
- elems.append(Paragraph("No lab inputs recorded.", body))
1154
- elems.append(Spacer(1,8))
1155
-
1156
- # Deterministic results
1157
- elems.append(Paragraph("Deterministic Classification Results", h1))
1158
- elems.append(Paragraph(f"USCS: {classification.get('USCS_code','N/A')} — {classification.get('USCS_description','')}", body))
1159
- elems.append(Paragraph(f"AASHTO: {classification.get('AASHTO_code','N/A')} — {classification.get('AASHTO_description','')}", body))
1160
- elems.append(Paragraph(f"Group Index: {classification.get('GI','N/A')}", body))
1161
- elems.append(Spacer(1,6))
1162
- elems.append(Paragraph("USCS decision path (verbatim):", h1))
1163
- elems.append(Paragraph(classification.get("USCS_decision_path","Not recorded"), body))
1164
- elems.append(Spacer(1,6))
1165
- elems.append(Paragraph("AASHTO decision path (verbatim):", h1))
1166
- elems.append(Paragraph(classification.get("AASHTO_decision_path","Not recorded"), body))
1167
- elems.append(Spacer(1,8))
1168
-
1169
- # Engineering characteristics table
1170
- elems.append(Paragraph("Engineering Characteristics (from reference table)", h1))
1171
- eng = classification.get("engineering_characteristics", {})
1172
- if eng:
1173
- eng_data = [["Property","Value"]]
1174
- for k,v in eng.items():
1175
- eng_data.append([k, v])
1176
- t2 = Table(eng_data, colWidths=[60*mm, 100*mm])
1177
- t2.setStyle(TableStyle([("GRID",(0,0),(-1,-1),0.5,colors.grey), ("BACKGROUND",(0,0),(-1,0),colors.HexColor("#FF6600")), ("TEXTCOLOR",(0,0),(-1,0),colors.white)]))
1178
- elems.append(t2)
1179
- elems.append(Spacer(1,8))
1180
-
1181
- # LLM Explanation (humanized)
1182
- elems.append(Paragraph("Humanized Engineering Explanation (LLM)", h1))
1183
- if explanation_text:
1184
- # avoid overly long text blocks; split into paragraphs
1185
- for para in explanation_text.strip().split("\n\n"):
1186
- elems.append(Paragraph(para.strip().replace("\n"," "), body))
1187
- elems.append(Spacer(1,4))
1188
- else:
1189
- elems.append(Paragraph("No LLM explanation available.", body))
1190
-
1191
- # Map snapshot (optional)
1192
- if "map_snapshot" in site and site["map_snapshot"]:
1193
- snap = site["map_snapshot"]
1194
- # If snapshot is HTML, skip embedding; if it's an image path, include it.
1195
- if isinstance(snap, str) and snap.lower().endswith((".png",".jpg",".jpeg")) and os.path.exists(snap):
1196
- elems.append(PageBreak())
1197
- elems.append(Paragraph("Map Snapshot", h1))
1198
- elems.append(RLImage(snap, width=160*mm, height=90*mm))
1199
-
1200
- doc.build(elems)
1201
- pdf_bytes = buf.getvalue()
1202
- buf.close()
1203
- return pdf_bytes
1204
-
1205
- # ----------------------------
1206
- # Streamlit Chat-style Soil Classifier Page
1207
- # ----------------------------
1208
- def soil_classifier_page():
1209
- st.header("🧭 Soil Classifier — USCS & AASHTO (Verbatim)")
1210
-
1211
- site = get_active_site()
1212
- if site is None:
1213
- st.warning("No active site. Add a site first in the sidebar.")
1214
- return
1215
-
1216
- # Ensure classifier_inputs exists
1217
- site.setdefault("classifier_inputs", {})
1218
-
1219
- col1, col2 = st.columns([2,1])
1220
- with col1:
1221
- st.markdown("**Upload lab sheet (image) for OCR** — the extracted values will auto-fill classifier inputs.")
1222
- uploaded = st.file_uploader("Upload image (png/jpg)", type=["png","jpg","jpeg"], key="clf_ocr_upload")
1223
- if uploaded:
1224
- img = Image.open(uploaded)
1225
- st.image(img, caption="Uploaded lab sheet (OCR)", use_column_width=True)
1226
- try:
1227
- raw_text = pytesseract.image_to_string(img)
1228
- st.text_area("OCR raw text (preview)", raw_text, height=180)
1229
- # Basic numeric extraction heuristics (LL, PL, P200, P4, D60/D30/D10)
1230
- # Try many patterns for robustness
1231
- def find_first(pattern):
1232
- m = re.search(pattern, raw_text, re.IGNORECASE)
1233
- return float(m.group(1)) if m else None
1234
-
1235
- possible = {}
1236
- for pat_key, pats in {
1237
- "LL": [r"LL\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)", r"Liquid\s*Limit\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
1238
- "PL": [r"PL\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)", r"Plastic\s*Limit\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
1239
- "P200":[r"%\s*Passing\s*#?200\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)", r"P200\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)", r"Passing\s*0\.075\s*mm\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
1240
- "P4":[r"%\s*Passing\s*#?4\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)", r"P4\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
1241
- "D60":[r"D60\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)", r"D_{60}\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
1242
- "D30":[r"D30\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
1243
- "D10":[r"D10\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"]
1244
- }.items():
1245
- for p in pats:
1246
- v = find_first(p)
1247
- if v is not None:
1248
- possible[pat_key] = v
1249
- break
1250
- # copy found to site inputs
1251
- for k,v in possible.items():
1252
- site["classifier_inputs"][k] = v
1253
- save_active_site(site)
1254
- st.success(f"OCR auto-filled: {', '.join([f'{k}={v}' for k,v in possible.items()])}")
1255
- except Exception as e:
1256
- st.error(f"OCR parsing failed: {e}")
1257
-
1258
- st.markdown("**Or type soil parameters / paste lab line** (e.g. `LL=45 PL=22 P200=58 P4=12 D60=1.2 D30=0.45 D10=0.08`) — chat-style input below.")
1259
- user_text = st.text_area("Enter parameters or notes", value="", key="clf_text_input", height=120)
1260
-
1261
- if st.button("Run Classification"):
1262
- # parse user_text for numbers too (merge with site inputs)
1263
- txt = user_text or ""
1264
- # find key=value pairs
1265
- kvs = dict(re.findall(r"([A-Za-z0-9_%]+)\s*[=:\-]\s*([0-9]+(?:\.[0-9]+)?)", txt))
1266
- # normalize keys
1267
- norm = {}
1268
- for k,v in kvs.items():
1269
- klow = k.strip().lower()
1270
- if klow in ("ll","liquidlimit","liquid_limit","liquid"):
1271
- norm["LL"] = float(v)
1272
- elif klow in ("pl","plasticlimit","plastic_limit","plastic"):
1273
- norm["PL"] = float(v)
1274
- elif klow in ("pi","plasticityindex"):
1275
- norm["PI"] = float(v)
1276
- elif klow in ("p200","%200","p_200","passing200"):
1277
- norm["P200"] = float(v)
1278
- elif klow in ("p4","p_4","passing4"):
1279
- norm["P4"] = float(v)
1280
- elif klow in ("d60","d_60"):
1281
- norm["D60"] = float(v)
1282
- elif klow in ("d30","d_30"):
1283
- norm["D30"] = float(v)
1284
- elif klow in ("d10","d_10"):
1285
- norm["D10"] = float(v)
1286
- # merge into site inputs
1287
- site["classifier_inputs"].update(norm)
1288
- save_active_site(site)
1289
-
1290
- # run verbatim classifiers
1291
- inputs_for_class = site["classifier_inputs"]
1292
- # ensure keys exist (coerce to numeric defaults)
1293
- result = classify_all(inputs_for_class)
1294
- # store result into site memory
1295
- site["classification_report"] = result
1296
- save_active_site(site)
1297
-
1298
- st.success("Deterministic classification complete.")
1299
- st.markdown("**USCS result:** " + str(result.get("USCS_code")))
1300
- st.markdown("**AASHTO result:** " + str(result.get("AASHTO_code")) + f" (GI={result.get('GI')})")
1301
- st.markdown("**Engineering summary (deterministic):**")
1302
- st.info(result.get("engineering_summary"))
1303
-
1304
- # call LLM to produce a humanized expanded report (if GROQ key exists)
1305
- prompt = f"""
1306
- You are GeoMate, a professional geotechnical engineer assistant.
1307
- Given the following laboratory inputs and deterministic classification, produce a clear, technical
1308
- and human-friendly classification report, explaining what the soil is, how it behaves, engineering
1309
- implications (bearing, settlement, stiffness), suitability for shallow foundations and road subgrades,
1310
- and practical recommendations for site engineering.
1311
-
1312
- Site: {site.get('Site Name','Unnamed')}
1313
- Inputs (as parsed): {json.dumps(site.get('classifier_inputs',{}), indent=2)}
1314
- Deterministic classification results:
1315
- USCS: {result.get('USCS_code')}
1316
- USCS decision path: {result.get('USCS_decision_path')}
1317
- AASHTO: {result.get('AASHTO_code')}
1318
- AASHTO decision path: {result.get('AASHTO_decision_path')}
1319
- Group Index: {result.get('GI')}
1320
- Engineering characteristics reference table: {json.dumps(result.get('engineering_characteristics',{}), indent=2)}
1321
-
1322
- Provide:
1323
- - Executive summary (3-5 sentences)
1324
- - Engineering interpretation (detailed)
1325
- - Specific recommendations (foundations, drainage, compaction, stabilization)
1326
- - Short checklist of items for further testing.
1327
- """
1328
- st.info("Generating humanized report via LLM (Groq) — this may take a few seconds.")
1329
- explanation = call_groq_for_explanation(prompt)
1330
- # fallback if failed
1331
- if explanation.startswith("LLM call failed") or explanation.startswith("Groq API key not found"):
1332
- # build local humanized explanation deterministically
1333
- explanation = ("Humanized explanation not available via LLM. "
1334
- "Deterministic summary: \n\n" + result.get("engineering_summary", "No summary."))
1335
-
1336
- # save explanation to site memory
1337
- site.setdefault("reports", {})
1338
- site["reports"]["last_classification_explanation"] = explanation
1339
- save_active_site(site)
1340
-
1341
- st.markdown("**Humanized Explanation (LLM or fallback):**")
1342
- st.write(explanation)
1343
-
1344
- # Build PDF bytes and offer download
1345
- pdf_bytes = build_classification_pdf_bytes(site, result, explanation)
1346
- st.download_button("Download Classification PDF", data=pdf_bytes, file_name=f"classification_{site.get('Site Name','site')}.pdf", mime="application/pdf")
1347
-
1348
- # side column shows current parsed inputs / last results
1349
- with col2:
1350
- st.markdown("**Current parsed inputs**")
1351
- st.json(site.get("classifier_inputs", {}))
1352
- st.markdown("**Last deterministic classification (if any)**")
1353
- st.json(site.get("classification_report", {}))
1354
-
1355
- # End of snippet
1356
-
1357
- pass
1358
-
1359
-
1360
- # 3. Locator (Earth Engine + Maps)
1361
- def locator_page():
1362
- st.header("🌍 Locator (Earth Engine Powered)")
1363
- # TODO: implement EE init + fetch flood, seismic, topo, soil
1364
- pass
1365
-
1366
-
1367
- # 4. RAG Chatbot (FAISS + Groq)
1368
- def rag_chatbot_page():
1369
- st.header("💬 Knowledge Assistant (RAG + Groq)")
1370
- # TODO: implement FAISS search + Groq LLM API
1371
- pass
1372
-
1373
-
1374
- # 5. PDF Report Generator
1375
- def report_page():
1376
- st.header("📑 Generate Report")
1377
- # TODO: compile site data → PDF download
1378
- pass
1379
-
1380
-
1381
- # 6. Feedback Form
1382
- def feedback_page():
1383
- st.header("📝 Feedback & Suggestions")
1384
- # TODO: implement form → send email ([email protected])
1385
- pass
1386
-
1387
-
1388
- # =============================
1389
- # NAVIGATION
1390
- # =============================
1391
-
1392
- PAGES = {
1393
- "Soil Recognizer": soil_recognizer_page,
1394
- "Soil Classifier": soil_classifier_page,
1395
- "Locator": locator_page,
1396
- "Knowledge Assistant": rag_chatbot_page,
1397
- "Report": report_page,
1398
- "Feedback": feedback_page,
1399
- }
1400
-
1401
- def main():
1402
- st.sidebar.title("🌍 GeoMate V2")
1403
- choice = st.sidebar.radio("Navigate", list(PAGES.keys()))
1404
-
1405
- # Site memory: add/manage multiple sites
1406
- if st.sidebar.button("➕ Add Site"):
1407
- st.session_state["sites"].append({})
1408
- st.session_state["active_site_idx"] = len(st.session_state["sites"]) - 1
1409
- if st.session_state["sites"]:
1410
- st.sidebar.write("Sites:")
1411
- for i, s in enumerate(st.session_state["sites"]):
1412
- label = f"Site {i+1}"
1413
- if st.sidebar.button(label, key=f"site_{i}"):
1414
- st.session_state["active_site_idx"] = i
1415
-
1416
- # Run selected page
1417
- PAGES[choice]()
1418
-
1419
-
1420
- if __name__ == "__main__":
1421
- main()