MSU576 commited on
Commit
614867d
·
verified ·
1 Parent(s): c896f8f

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -1191
app.py DELETED
@@ -1,1191 +0,0 @@
1
- # app.py - GeoMate V2 (single-file Streamlit app)
2
- # Large, feature-rich application implementing many GeoMate V2 features:
3
- # - Multi-site session-state (up to 4 sites)
4
- # - Landing page (fancy UI)
5
- # - Sidebar with model selector + site manager
6
- # - Soil Classifier (chatbot-style, stepwise, retains inputs, text inputs to avoid +/- steppers)
7
- # - OCR option (EasyOCR) to auto-extract classification parameters from images
8
- # - Locator page with map drawing (folium fallback if Earth Engine unavailable)
9
- # - GeoMate Ask (RAG placeholder; per-site memory stored)
10
- # - Reports: Basic (10-15 params) and Detailed (all parameters); PDF export
11
- # - Site comparison feature (compare multiple sites when "Nature of Construction" is same)
12
- #
13
- # NOTE: This is a big single-file app. Adjust imports/requirements as necessary.
14
-
15
- import os
16
- import io
17
- import json
18
- import re
19
- import datetime
20
- from typing import Dict, Any, List, Optional, Tuple
21
-
22
- import streamlit as st
23
-
24
- # Set page config at top
25
- st.set_page_config(page_title="GeoMate V2", page_icon="🌍", layout="wide")
26
-
27
- # -------------------------
28
- # Imports that may be optional
29
- # -------------------------
30
- # OCR: easyocr (optional)
31
- try:
32
- import easyocr
33
- HAVE_EASYOCR = True
34
- except Exception:
35
- HAVE_EASYOCR = False
36
-
37
- # Map: folium + streamlit_folium as fallback
38
- try:
39
- import folium
40
- from streamlit_folium import st_folium
41
- HAVE_FOLIUM = True
42
- except Exception:
43
- HAVE_FOLIUM = False
44
-
45
- # Report / PDF
46
- try:
47
- from reportlab.lib.pagesizes import A4
48
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
49
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, Image
50
- from reportlab.lib import colors
51
- HAVE_REPORTLAB = True
52
- except Exception:
53
- HAVE_REPORTLAB = False
54
-
55
- # Attempt to import geemap / earthengine (may fail without credentials)
56
- try:
57
- import ee
58
- import geemap.foliumap as geemap
59
- HAVE_EE = True
60
- except Exception:
61
- HAVE_EE = False
62
-
63
- # Groq placeholder: we will not crash if groq is missing; just show warnings
64
- try:
65
- from groq import Groq
66
- HAVE_GROQ = True
67
- except Exception:
68
- HAVE_GROQ = False
69
-
70
- # ---------- Helper / Config ----------
71
- MAX_SITES = 4
72
-
73
- # Initialize session state shortcut
74
- ss = st.session_state
75
-
76
- # Initialize default session keys
77
- if "site_descriptions" not in ss:
78
- # site_descriptions is list of dicts, one per site
79
- ss["site_descriptions"] = []
80
- if "active_site_index" not in ss:
81
- ss["active_site_index"] = 0
82
- if "llm_model" not in ss:
83
- ss["llm_model"] = "llama3-8b-8192"
84
- if "secrets_status" not in ss:
85
- ss["secrets_status"] = {"groq_ok": False, "ee_ok": False}
86
- if "global_chat_history" not in ss:
87
- ss["global_chat_history"] = [] # optional general chat log
88
-
89
- # ---------- Secrets check (warn but continue) ----------
90
- def check_secrets():
91
- """Check environment for relevant secrets and update ss.secrets_status."""
92
- groq_key = os.environ.get("GROQ_API_KEY") or os.environ.get("GROQ_KEY")
93
- service_account = os.environ.get("SERVICE_ACCOUNT") or os.environ.get("EE_SERVICE_ACCOUNT") or os.environ.get("EARTH_ENGINE_KEY")
94
- # Update statuses (we don't stop the app; we just show warnings)
95
- ss["secrets_status"]["groq_ok"] = bool(groq_key)
96
- ss["secrets_status"]["ee_ok"] = bool(service_account)
97
- return ss["secrets_status"]
98
-
99
- check_secrets()
100
-
101
- # ---------- Utilities ----------
102
- def now_str():
103
- return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
104
-
105
- def ensure_site(index: int):
106
- """Ensure that a site dict exists at site_descriptions[index]."""
107
- while len(ss.site_descriptions) <= index:
108
- # default structure for a site
109
- ss.site_descriptions.append({
110
- "Site Name": f"Site-{len(ss.site_descriptions)+1}",
111
- "Site Coordinates": "",
112
- "lat": None,
113
- "lon": None,
114
- "Load Bearing Capacity": None,
115
- "Skin Shear Strength": None,
116
- "Relative Compaction": None,
117
- "Rate of Consolidation": None,
118
- "Nature of Construction": None,
119
- "Soil Profile": None,
120
- "Flood Data": None,
121
- "Seismic Data": None,
122
- "GSD": None,
123
- "USCS": None,
124
- "AASHTO": None,
125
- "GI": None,
126
- "classifier_inputs": {},
127
- "classifier_decision_path": "",
128
- "chat_history": [],
129
- "report_convo_state": 0,
130
- "map_snapshot": None,
131
- "classifier_state": 0,
132
- "classifier_chat": []
133
- })
134
- return ss.site_descriptions[index]
135
-
136
- def active_site():
137
- idx = ss.active_site_index if 0 <= ss.active_site_index < len(ss.site_descriptions) else 0
138
- ensure_site(idx)
139
- return idx, ss.site_descriptions[idx]
140
-
141
- # ---------- OCR extraction helper ----------
142
- ocr_reader = None
143
- if HAVE_EASYOCR:
144
- try:
145
- # single-language English by default
146
- ocr_reader = easyocr.Reader(["en"], gpu=False)
147
- except Exception:
148
- ocr_reader = None
149
-
150
- def parse_numbers_from_text(text: str) -> Dict[str, float]:
151
- """
152
- Attempt to extract common classification numbers from text:
153
- - % passing #200 (P2)
154
- - % passing #4 (P4)
155
- - D60, D30, D10
156
- - LL, PL
157
- Returns dict of found floats.
158
- """
159
- out = {}
160
- # common patterns
161
- # e.g., "% Passing sieve #200: 35" or "P2 = 35%"
162
- patterns = {
163
- "P2": [r"%\s*passing\s*#?200[:\s]*([0-9]+(?:\.[0-9]+)?)",
164
- r"P2[:\s=]*([0-9]+(?:\.[0-9]+)?)"],
165
- "P4": [r"%\s*passing\s*#?4[:\s]*([0-9]+(?:\.[0-9]+)?)",
166
- r"P4[:\s=]*([0-9]+(?:\.[0-9]+)?)"],
167
- "D60": [r"D60[:\s=]*([0-9]+(?:\.[0-9]+)?)\s*mm?",
168
- r"D60\s*=\s*([0-9]+(?:\.[0-9]+)?)"],
169
- "D30": [r"D30[:\s=]*([0-9]+(?:\.[0-9]+)?)\s*mm?",
170
- r"D30\s*=\s*([0-9]+(?:\.[0-9]+)?)"],
171
- "D10": [r"D10[:\s=]*([0-9]+(?:\.[0-9]+)?)\s*mm?",
172
- r"D10\s*=\s*([0-9]+(?:\.[0-9]+)?)"],
173
- "LL": [r"Liquid\s*Limit\s*(?:[:=])\s*([0-9]+(?:\.[0-9]+)?)",
174
- r"LL[:\s=]*([0-9]+(?:\.[0-9]+)?)"],
175
- "PL": [r"Plastic\s*Limit\s*(?:[:=])\s*([0-9]+(?:\.[0-9]+)?)",
176
- r"PL[:\s=]*([0-9]+(?:\.[0-9]+)?)"]
177
- }
178
- for key, pats in patterns.items():
179
- for p in pats:
180
- m = re.search(p, text, flags=re.I)
181
- if m:
182
- try:
183
- val = float(m.group(1))
184
- out[key] = val
185
- break
186
- except:
187
- continue
188
- return out
189
-
190
- def run_ocr_on_image_bytes(img_bytes: bytes) -> str:
191
- """Run OCR on uploaded image bytes and return extracted plain text."""
192
- if not ocr_reader:
193
- return ""
194
- try:
195
- import numpy as np
196
- from PIL import Image
197
- img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
198
- arr = np.array(img)
199
- results = ocr_reader.readtext(arr, detail=0)
200
- text = "\n".join(results)
201
- return text
202
- except Exception as e:
203
- return ""
204
-
205
- # -------------------------
206
- # Soil classification logic (verbatim adapted)
207
- # -------------------------
208
- from math import floor
209
-
210
- # Engineering characteristics (small sample)
211
- ENGINEERING_CHARACTERISTICS = {
212
- "Gravel": {"Settlement": "None", "Quicksand": "Impossible"},
213
- "Coarse sand": {"Settlement": "None", "Quicksand": "Impossible"},
214
- "Silt": {"Settlement": "Occurs", "Quicksand": "Liable"},
215
- "Clay": {"Settlement": "Occurs", "Quicksand": "Impossible"}
216
- }
217
-
218
- def uscs_aashto_verbatim(inputs: Dict[str, Any]) -> Tuple[str, str, str, int, Dict[str,str]]:
219
- """
220
- Implements USCS & AASHTO logic adapted from your earlier script.
221
- Returns (result_text, uscs_sym, aashto_sym, GI, char_summary)
222
- """
223
- # safe parse
224
- opt = str(inputs.get("opt", "n")).lower()
225
- if opt == 'y':
226
- uscs = "Pt"
227
- uscs_expl = "Peat / organic soil — compressible, high organic content; poor engineering properties."
228
- aashto = "Organic (special handling)"
229
- GI = 0
230
- chars = {"summary":"Highly organic peat — large settlement, low strength, not suitable for foundations."}
231
- result_text = f"According to USCS, the soil is {uscs} — {uscs_expl}\nAccording to AASHTO, the soil is {aashto} (Group Index = {GI})"
232
- return result_text, uscs, aashto, GI, chars
233
-
234
- # numeric inputs
235
- try:
236
- P2 = float(inputs.get("P2", 0.0))
237
- except:
238
- P2 = 0.0
239
- try:
240
- P4 = float(inputs.get("P4", 0.0))
241
- except:
242
- P4 = 0.0
243
- try:
244
- D60 = float(inputs.get("D60", 0.0))
245
- D30 = float(inputs.get("D30", 0.0))
246
- D10 = float(inputs.get("D10", 0.0))
247
- except:
248
- D60 = D30 = D10 = 0.0
249
- try:
250
- LL = float(inputs.get("LL", 0.0))
251
- PL = float(inputs.get("PL", 0.0))
252
- except:
253
- LL = PL = 0.0
254
- PI = LL - PL
255
-
256
- Cu = (D60 / D10) if (D10 > 0 and D60 > 0) else 0
257
- Cc = ((D30 ** 2) / (D10 * D60)) if (D10 > 0 and D30 > 0 and D60 > 0) else 0
258
-
259
- # default placeholders
260
- uscs = "Unknown"
261
- uscs_expl = ""
262
- aashto = "Unknown"
263
-
264
- # USCS logic
265
- if P2 <= 50:
266
- if P4 <= 50:
267
- # Gravels
268
- if Cu and Cc:
269
- if Cu >= 4 and 1 <= Cc <= 3:
270
- uscs = "GW"; uscs_expl = "Well-graded gravel (good engineering properties)."
271
- else:
272
- uscs = "GP"; uscs_expl = "Poorly-graded gravel."
273
- else:
274
- if PI < 4 or PI < 0.73 * (LL - 20):
275
- uscs = "GM"; uscs_expl = "Silty gravel."
276
- elif PI > 7 and PI > 0.73 * (LL - 20):
277
- uscs = "GC"; uscs_expl = "Clayey gravel."
278
- else:
279
- uscs = "GM-GC"; uscs_expl = "Mixed fines."
280
- else:
281
- # Sands
282
- if Cu and Cc:
283
- if Cu >= 6 and 1 <= Cc <= 3:
284
- uscs = "SW"; uscs_expl = "Well-graded sand."
285
- else:
286
- uscs = "SP"; uscs_expl = "Poorly-graded sand."
287
- else:
288
- if PI < 4 or PI <= 0.73 * (LL - 20):
289
- uscs = "SM"; uscs_expl = "Silty sand."
290
- elif PI > 7 and PI > 0.73 * (LL - 20):
291
- uscs = "SC"; uscs_expl = "Clayey sand."
292
- else:
293
- uscs = "SM-SC"; uscs_expl = "Transition silty/clayey sand."
294
- else:
295
- # fine soils (P2 > 50)
296
- nDS = int(inputs.get("nDS", 6))
297
- nDIL = int(inputs.get("nDIL", 6))
298
- nTG = int(inputs.get("nTG", 6))
299
- if LL < 50:
300
- if 20 <= LL < 50 and PI <= 0.73 * (LL - 20):
301
- if nDS == 1 or nDIL == 3 or nTG == 3:
302
- uscs, uscs_expl = "ML", "Silt (low plasticity)."
303
- elif nDS == 3 or nDIL == 3 or nTG == 3:
304
- uscs, uscs_expl = "OL", "Organic silt."
305
- else:
306
- uscs, uscs_expl = "ML-OL", "Mixed silt/organic."
307
- elif 10 <= LL <= 30 and 4 <= PI <= 7 and PI > 0.72 * (LL - 20):
308
- if nDS == 1 or nDIL == 1 or nTG == 1:
309
- uscs, uscs_expl = "ML", "Silt."
310
- elif nDS == 2 or nDIL == 2 or nTG == 2:
311
- uscs, uscs_expl = "CL", "Clay (low plasticity)."
312
- else:
313
- uscs, uscs_expl = "ML-CL", "Mixed silt/clay."
314
- else:
315
- uscs, uscs_expl = "CL", "Clay (low plasticity)."
316
- else:
317
- if PI < 0.73 * (LL - 20):
318
- if nDS == 3 or nDIL == 4 or nTG == 4:
319
- uscs, uscs_expl = "MH", "Silt (high plasticity)."
320
- elif nDS == 2 or nDIL == 2 or nTG == 4:
321
- uscs, uscs_expl = "OH", "Organic clay/silt (high plasticity)."
322
- else:
323
- uscs, uscs_expl = "MH-OH", "Mixed high-plasticity."
324
- else:
325
- uscs, uscs_expl = "CH", "Clay (high plasticity)."
326
-
327
- # AASHTO logic
328
- if P2 <= 35:
329
- if P2 <= 15 and P4 <= 30 and PI <= 6:
330
- aashto = "A-1-a"
331
- elif P2 <= 25 and P4 <= 50 and PI <= 6:
332
- aashto = "A-1-b"
333
- elif P2 <= 35 and P4 > 0:
334
- if LL <= 40 and PI <= 10: aashto = "A-2-4"
335
- elif LL >= 41 and PI <= 10: aashto = "A-2-5"
336
- elif LL <= 40 and PI >= 11: aashto = "A-2-6"
337
- elif LL >= 41 and PI >= 11: aashto = "A-2-7"
338
- else: aashto = "A-2"
339
- else: aashto = "A-3"
340
- else:
341
- if LL <= 40 and PI <= 10: aashto = "A-4"
342
- elif LL >= 41 and PI <= 10: aashto = "A-5"
343
- elif LL <= 40 and PI >= 11: aashto = "A-6"
344
- else: aashto = "A-7-5" if PI <= (LL - 30) else "A-7-6"
345
-
346
- a = P2 - 35
347
- a = 0 if a < 0 else (40 if a > 40 else a)
348
- b = P2 - 15
349
- b = 0 if b < 0 else (40 if b > 40 else b)
350
- c = LL - 40
351
- c = 0 if c < 0 else (20 if c > 20 else c)
352
- d = PI - 10
353
- d = 0 if d < 0 else (20 if d > 20 else d)
354
- GI = floor(0.2*a + 0.005*a*c + 0.01*b*d)
355
-
356
- aashto_expl = f"{aashto} (GI = {GI})"
357
-
358
- # choose engineering char summary
359
- if uscs.startswith(("G","S")):
360
- char_summary = ENGINEERING_CHARACTERISTICS.get("Coarse sand", {})
361
- elif uscs.startswith(("M","C","O","H")):
362
- char_summary = ENGINEERING_CHARACTERISTICS.get("Silt", {})
363
- else:
364
- char_summary = ENGINEERING_CHARACTERISTICS.get("Silt", {})
365
-
366
- result_text = f"USCS: **{uscs}** — {uscs_expl}\n\nAASHTO: **{aashto_expl}**"
367
- return result_text, uscs, aashto, GI, char_summary
368
-
369
- # -------------------------
370
- # UI components
371
- # -------------------------
372
-
373
- # ---------- CSS for styling (bubbles, rounded corners, active border) ----------
374
- def inject_css():
375
- st.markdown(
376
- """
377
- <style>
378
- /* App background and fonts */
379
- .stApp {
380
- background: linear-gradient(180deg, #050505 0%, #0b0b0b 100%);
381
- color: #e8eef8;
382
- font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
383
- }
384
- /* Landing hero card */
385
- .hero-card {
386
- background: linear-gradient(180deg, rgba(255,122,0,0.06), rgba(31,78,121,0.02));
387
- border-radius: 14px;
388
- padding: 18px;
389
- border: 1px solid rgba(255,122,0,0.08);
390
- }
391
- /* Chat bubble - bot */
392
- .chat-bot {
393
- background: #111111;
394
- border-left: 4px solid #FF7A00;
395
- padding: 12px;
396
- border-radius: 12px;
397
- margin-bottom:8px;
398
- }
399
- /* Chat bubble - user */
400
- .chat-user {
401
- background: linear-gradient(90deg,#1f4e79,#0f62fe);
402
- padding: 10px;
403
- border-radius: 12px;
404
- color: white;
405
- margin-bottom:8px;
406
- text-align: right;
407
- }
408
- /* small muted text */
409
- .muted { color: #97a8c6; font-size:13px }
410
- /* fancy sidebar active item */
411
- .option-selected { border-left: 4px solid #FF7A00 !important; padding-left:8px !important; }
412
- </style>
413
- """,
414
- unsafe_allow_html=True
415
- )
416
-
417
- inject_css()
418
-
419
- # ---------- Landing page ----------
420
- def landing_page():
421
- st.markdown("<div style='display:flex;align-items:center;gap:12px'>"
422
- "<div style='width:72px;height:72px;border-radius:16px;background:linear-gradient(135deg,#ff7a00,#ff3a3a);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 28px rgba(0,0,0,0.6)'>"
423
- "<span style='font-size:36px'>🛰️</span></div>"
424
- "<div><h1 style='margin:0;color:#FF8C00'>GeoMate V2</h1>"
425
- "<div class='muted'>AI geotechnical copilot — soil recognition, classification, locator, RAG, and reports</div></div></div>",
426
- unsafe_allow_html=True)
427
-
428
- st.markdown("---")
429
- col1, col2 = st.columns([3,1])
430
- with col1:
431
- st.markdown("<div class='hero-card'>", unsafe_allow_html=True)
432
- st.subheader("A smarter way to do geotechnical investigations")
433
- st.write("""
434
- GeoMate integrates soil classification (USCS & AASHTO), GSD plotting, OCR from images, site locator with mapping,
435
- a RAG-powered technical assistant, and professional report generation — all inside Streamlit.
436
- Use the sidebar to create project sites, pick an LLM model, and navigate between tools.
437
- """)
438
- st.markdown("</div>", unsafe_allow_html=True)
439
- st.markdown("### Quick Actions")
440
- c1, c2, c3 = st.columns(3)
441
- if c1.button("🧪 Soil Classifier"):
442
- ss.active_site_index = 0
443
- st.experimental_rerun()
444
- if c2.button("🌍 Locator"):
445
- st.experimental_rerun()
446
- if c3.button("📑 Reports"):
447
- st.experimental_rerun()
448
- with col2:
449
- st.markdown("<div class='hero-card' style='text-align:center'>", unsafe_allow_html=True)
450
- st.markdown(f"<div style='font-size:22px;color:#FF7A00;font-weight:700'>{len(ss.site_descriptions)}</div>", unsafe_allow_html=True)
451
- st.markdown("<div class='muted'>Sites configured</div>", unsafe_allow_html=True)
452
- st.markdown("</div>", unsafe_allow_html=True)
453
-
454
- # show warnings about secrets in landing page but not blocking
455
- if not ss.secrets_status.get("groq_ok", False):
456
- st.warning("GROQ / LLM key not found in environment. RAG features will be limited. Set GROQ_API_KEY to enable LLM.")
457
- if not ss.secrets_status.get("ee_ok", False):
458
- st.info("Earth Engine service account not found. Locator will use fallback maps.")
459
-
460
- # ---------- Sidebar ----------
461
- def sidebar_ui():
462
- st.sidebar.markdown("<div style='text-align:center; padding:6px 0;'><h3 style='color:#FF8C00; margin:0;'>GeoMate V2</h3></div>", unsafe_allow_html=True)
463
- # model selector
464
- st.sidebar.markdown("### LLM Model")
465
- model = st.sidebar.selectbox("Select model", ["llama3-8b-8192", "gemma-7b-it", "mixtral-8x7b-32768"], index=0)
466
- ss.llm_model = model
467
-
468
- st.sidebar.markdown("---")
469
- # Manage sites
470
- st.sidebar.markdown("### Project Sites")
471
- colA, colB = st.sidebar.columns([3,1])
472
- new_site_name = colA.text_input("New site name", key="new_site_name_input")
473
- if colB.button("➕", help="Add new site"):
474
- if new_site_name:
475
- if len(ss.site_descriptions) >= MAX_SITES:
476
- st.sidebar.error(f"Maximum {MAX_SITES} sites allowed.")
477
- else:
478
- ss.site_descriptions.append({
479
- "Site Name": new_site_name,
480
- "Site Coordinates": "",
481
- "lat": None,
482
- "lon": None,
483
- "Load Bearing Capacity": None,
484
- "Skin Shear Strength": None,
485
- "Relative Compaction": None,
486
- "Rate of Consolidation": None,
487
- "Nature of Construction": None,
488
- "Soil Profile": None,
489
- "Flood Data": None,
490
- "Seismic Data": None,
491
- "GSD": None,
492
- "USCS": None,
493
- "AASHTO": None,
494
- "GI": None,
495
- "classifier_inputs": {},
496
- "classifier_decision_path": "",
497
- "chat_history": [],
498
- "report_convo_state": 0,
499
- "map_snapshot": None,
500
- "classifier_state": 0,
501
- "classifier_chat": []
502
- })
503
- ss.active_site_index = len(ss.site_descriptions)-1
504
- st.sidebar.success(f"Site '{new_site_name}' added.")
505
- st.experimental_rerun()
506
- else:
507
- st.sidebar.error("Enter a site name first.")
508
-
509
- # Select active site
510
- if ss.site_descriptions:
511
- site_names = [s.get("Site Name", f"Site-{i+1}") for i,s in enumerate(ss.site_descriptions)]
512
- chosen = st.sidebar.selectbox("Active Site", site_names, index=min(ss.active_site_index, len(site_names)-1))
513
- ss.active_site_index = site_names.index(chosen)
514
- else:
515
- st.sidebar.info("No sites yet. Add a site above.")
516
-
517
- st.sidebar.markdown("---")
518
- # Manage sites
519
- st.sidebar.markdown("### Project Sites")
520
- colA, colB = st.sidebar.columns([3,1])
521
- new_site_name = colA.text_input("New site name", key="new_site_name_input")
522
- if colB.button("➕", help="Add new site"):
523
- if new_site_name:
524
- if len(ss.site_descriptions) >= MAX_SITES:
525
- st.sidebar.error(f"Maximum {MAX_SITES} sites allowed.")
526
- else:
527
- ss.site_descriptions.append({
528
- "Site Name": new_site_name,
529
- "Site Coordinates": "",
530
- "lat": None,
531
- "lon": None,
532
- "Load Bearing Capacity": None,
533
- "Skin Shear Strength": None,
534
- "Relative Compaction": None,
535
- "Rate of Consolidation": None,
536
- "Nature of Construction": None,
537
- "Soil Profile": None,
538
- "Flood Data": None,
539
- "Seismic Data": None,
540
- "GSD": None,
541
- "USCS": None,
542
- "AASHTO": None,
543
- "GI": None,
544
- "classifier_inputs": {},
545
- "classifier_decision_path": "",
546
- "chat_history": [],
547
- "report_convo_state": 0,
548
- "map_snapshot": None,
549
- "classifier_state": 0,
550
- "classifier_chat": []
551
- })
552
- ss.active_site_index = len(ss.site_descriptions)-1
553
- st.sidebar.success(f"Site '{new_site_name}' added.")
554
- st.rerun()
555
- else:
556
- st.sidebar.error("Enter a site name first.")
557
-
558
- # Select active site
559
- if ss.site_descriptions:
560
- site_names = [s.get("Site Name", f"Site-{i+1}") for i,s in enumerate(ss.site_descriptions)]
561
- chosen = st.sidebar.selectbox("Active Site", site_names, index=min(ss.active_site_index, len(site_names)-1))
562
- ss.active_site_index = site_names.index(chosen)
563
- else:
564
- st.sidebar.info("No sites yet. Add a site above.")
565
-
566
- st.sidebar.markdown("---")
567
- if st.sidebar.button("🔄 Reset Session (clear data)", help="Clears session data (not code)", key="reset_session"):
568
- # careful clearing
569
- ss.site_descriptions = []
570
- ss.active_site_index = 0
571
- ss.global_chat_history = []
572
- st.rerun()
573
-
574
- # Show JSON of active site in expander
575
- st.sidebar.markdown("---")
576
- with st.sidebar.expander("Active Site JSON (live)"):
577
- if ss.site_descriptions:
578
- idx, site = active_site()
579
- st.json(site)
580
- else:
581
- st.write("No site configured.")
582
-
583
- # ---------- Soil Classifier Page (chatbot style, stepwise) ----------
584
- # The UI collects inputs step-by-step and stores them in site['classifier_inputs'].
585
- # User can type numeric values in text fields (no spinners), or use OCR to auto-fill.
586
-
587
- CLASSIFIER_QUESTIONS = [
588
- "Is the soil organic (contains high organic matter, feels spongy or odour)? (Yes/No)",
589
- "What is the percentage passing the #200 sieve (0.075 mm)? (e.g. 12)",
590
- "What is the percentage passing the sieve no. 4 (4.75 mm)? (e.g. 19)",
591
- "Do you know the D60, D30, and D10 diameters in mm? (Yes/No)",
592
- "Enter D60 (mm) (diameter corresponding to 60% passing), or leave blank if unknown.",
593
- "Enter D30 (mm) (diameter corresponding to 30% passing), or leave blank if unknown.",
594
- "Enter D10 (mm) (diameter corresponding to 10% passing), or leave blank if unknown.",
595
- "What is the Liquid Limit (LL)?",
596
- "What is the Plastic Limit (PL)?",
597
- "Select observed Dry Strength (see dropdown)",
598
- "Select observed Dilatancy (see dropdown)",
599
- "Select observed Toughness (see dropdown)",
600
- "Would you like to perform classification now? (Yes/No)"
601
- ]
602
-
603
- # dropdown mapping per your requested text labels
604
- DRY_STRENGTH_OPTIONS = [
605
- "1. None - slight",
606
- "2. Medium - high",
607
- "3. Slight - Medium",
608
- "4. High - Very high",
609
- "5. Null?"
610
- ]
611
- DILATANCY_OPTIONS = [
612
- "1. Quick to slow",
613
- "2. None to very slow",
614
- "3. Slow",
615
- "4. Slow to none",
616
- "5. None",
617
- "6. Null?"
618
- ]
619
- TOUGHNESS_OPTIONS = [
620
- "1. None",
621
- "2. Medium",
622
- "3. Slight?",
623
- "4. Slight to Medium?",
624
- "5. High",
625
- "6. Null?"
626
- ]
627
-
628
- def soil_classifier_page():
629
- st.markdown("## 📊 Soil Classifier (chatbot style)")
630
- # tabs: Manual, OCR, Results
631
- tab1, tab2, tab3 = st.tabs(["✍️ Manual Input", "📷 OCR Input", "📑 Results & Export"])
632
- idx, site = active_site()
633
-
634
- # ensure classifier_inputs exists
635
- if "classifier_inputs" not in site:
636
- site["classifier_inputs"] = {}
637
-
638
- # state index for classifier conversation
639
- if "classifier_state" not in site:
640
- site["classifier_state"] = 0
641
-
642
- with tab1:
643
- st.markdown("<div class='chat-bot'>🤖 GeoMate: Hello — I'll guide you step-by-step through soil classification.</div>", unsafe_allow_html=True)
644
- # Stepwise inputs presented in order, but keep all visible for convenience.
645
- # Use text_input for numbers (prevents steppers).
646
- c = site["classifier_inputs"]
647
-
648
- # Organic?
649
- opt = st.radio("Is the soil organic (contains high organic matter, feels spongy or odour)?", ["No", "Yes"], index=0 if c.get("opt","n")=="n" else 1)
650
- c["opt"] = "y" if opt == "Yes" else "n"
651
-
652
- # % passing #200
653
- p2 = st.text_input("Percentage passing the #200 sieve (0.075 mm) — enter numeric value:", value=str(c.get("P2","")) )
654
- try:
655
- c["P2"] = float(p2) if p2.strip()!="" else 0.0
656
- except:
657
- st.error("P2 must be a number.")
658
- # % passing #4
659
- p4 = st.text_input("Percentage passing the sieve no. 4 (4.75 mm) — enter numeric value:", value=str(c.get("P4","")) )
660
- try:
661
- c["P4"] = float(p4) if p4.strip()!="" else 0.0
662
- except:
663
- st.error("P4 must be a number.")
664
-
665
- # D values
666
- know_d = st.radio("Do you know D60/D30/D10 values? ", ["No", "Yes"], index=1 if c.get("D10",0)>0 else 0)
667
- if know_d == "Yes":
668
- d60 = st.text_input("D60 (mm):", value=str(c.get("D60","")))
669
- d30 = st.text_input("D30 (mm):", value=str(c.get("D30","")))
670
- d10 = st.text_input("D10 (mm):", value=str(c.get("D10","")))
671
- try:
672
- c["D60"] = float(d60) if d60.strip()!="" else 0.0
673
- c["D30"] = float(d30) if d30.strip()!="" else 0.0
674
- c["D10"] = float(d10) if d10.strip()!="" else 0.0
675
- except:
676
- st.error("D-values must be numeric.")
677
- else:
678
- c["D60"] = float(c.get("D60",0.0))
679
- c["D30"] = float(c.get("D30",0.0))
680
- c["D10"] = float(c.get("D10",0.0))
681
-
682
- # LL and PL
683
- ll = st.text_input("Liquid Limit (LL):", value=str(c.get("LL","")))
684
- pl = st.text_input("Plastic Limit (PL):", value=str(c.get("PL","")))
685
- try:
686
- c["LL"] = float(ll) if ll.strip()!="" else 0.0
687
- c["PL"] = float(pl) if pl.strip()!="" else 0.0
688
- except:
689
- st.error("LL/PL must be numeric.")
690
-
691
- # fine soil descriptors (dropdowns) - map textual choices to numeric indices for logic
692
- ds_choice = st.selectbox("Dry Strength (select one):", DRY_STRENGTH_OPTIONS, index=DRY_STRENGTH_OPTIONS.index(c.get("nDS_text",DRY_STRENGTH_OPTIONS[2])) if c.get("nDS_text") in DRY_STRENGTH_OPTIONS else 2)
693
- dil_choice = st.selectbox("Dilatancy:", DILATANCY_OPTIONS, index=DILATANCY_OPTIONS.index(c.get("nDIL_text",DILATANCY_OPTIONS[0])) if c.get("nDIL_text") in DILATANCY_OPTIONS else 0)
694
- tg_choice = st.selectbox("Toughness:", TOUGHNESS_OPTIONS, index=TOUGHNESS_OPTIONS.index(c.get("nTG_text",TOUGHNESS_OPTIONS[0])) if c.get("nTG_text") in TOUGHNESS_OPTIONS else 0)
695
- # save text+index mapping
696
- c["nDS_text"] = ds_choice
697
- c["nDIL_text"] = dil_choice
698
- c["nTG_text"] = tg_choice
699
- # mapping from your requested textual options to numeric index used in classification logic
700
- dry_map = {DRY_STRENGTH_OPTIONS[i]: i+1 for i in range(len(DRY_STRENGTH_OPTIONS))}
701
- dil_map = {DILATANCY_OPTIONS[i]: i+1 for i in range(len(DILATANCY_OPTIONS))}
702
- tough_map = {TOUGHNESS_OPTIONS[i]: i+1 for i in range(len(TOUGHNESS_OPTIONS))}
703
- c["nDS"] = dry_map.get(ds_choice, 6)
704
- c["nDIL"] = dil_map.get(dil_choice, 6)
705
- c["nTG"] = tough_map.get(tg_choice, 6)
706
-
707
- # Save classifier inputs back
708
- site["classifier_inputs"] = c
709
-
710
- if st.button("🔍 Classify Soil (USCS & AASHTO)"):
711
- # Run classifier
712
- res_text, uscs_sym, aashto_sym, GI, char_summary = uscs_aashto_verbatim(site["classifier_inputs"])
713
- site["USCS"] = uscs_sym
714
- site["AASHTO"] = aashto_sym
715
- site["GI"] = GI
716
- site["classifier_decision_path"] = res_text
717
- # append to classifier chat
718
- site.setdefault("classifier_chat", []).append({"time": now_str(), "bot": "Classification completed.", "user_inputs": site["classifier_inputs"]})
719
- st.success("Classification complete. Results saved to site.")
720
- st.write(res_text)
721
- # show characteristics
722
- st.write("Engineering characteristics summary:")
723
- for k,v in char_summary.items():
724
- st.write(f"- **{k}**: {v}")
725
-
726
- with tab2:
727
- st.markdown("### OCR Input")
728
- st.info("Upload a photo (textbook or lab sheet). OCR will try to extract P2, P4, D10/30/60, LL, PL.")
729
- uploaded = st.file_uploader("Upload image (jpg/png)", type=["jpg","jpeg","png"])
730
- if uploaded:
731
- img_bytes = uploaded.read()
732
- st.image(img_bytes, use_column_width=True)
733
- if not HAVE_EASYOCR or ocr_reader is None:
734
- st.warning("EasyOCR not available on this environment. OCR will not run.")
735
- else:
736
- with st.spinner("Running OCR..."):
737
- text = run_ocr_on_image_bytes(img_bytes)
738
- st.text_area("Extracted text (preview)", value=text, height=200)
739
- parsed = parse_numbers_from_text(text)
740
- st.write("Parsed numeric values:", parsed)
741
- if st.button("Use parsed values to prefill classifier inputs"):
742
- # merge into classifier inputs
743
- for k,v in parsed.items():
744
- site["classifier_inputs"][k] = v
745
- st.success("Parsed values saved to classifier inputs. Go to Manual Input tab to review & classify.")
746
-
747
- with tab3:
748
- st.markdown("### Results & Export")
749
- if site.get("USCS") or site.get("AASHTO"):
750
- st.markdown(f"**USCS:** {site.get('USCS')} \n**AASHTO:** {site.get('AASHTO')} \n**Group Index (GI):** {site.get('GI')}")
751
- st.markdown("**Decision Path / Notes:**")
752
- st.write(site.get("classifier_decision_path", "Not available."))
753
- if st.button("📑 Export classification report (PDF)"):
754
- fname = export_classification_report_pdf(idx)
755
- with open(fname, "rb") as f:
756
- st.download_button("⬇️ Download PDF", data=f, file_name=fname, mime="application/pdf")
757
- else:
758
- st.info("No classification result yet. Use Manual Input or OCR to provide inputs, then click Classify.")
759
-
760
- # ---------- Export classification PDF ----------
761
- def export_classification_report_pdf(site_idx: int) -> str:
762
- site = ss.site_descriptions[site_idx]
763
- fname = f"classification_{site.get('Site Name','site')}.pdf"
764
- if not HAVE_REPORTLAB:
765
- # create a very simple fallback using bytes
766
- content = f"Classification Report for {site.get('Site Name')}\nUSCS: {site.get('USCS')}\nAASHTO: {site.get('AASHTO')}\nGI: {site.get('GI')}\n"
767
- with open(fname, "w") as f:
768
- f.write(content)
769
- return fname
770
-
771
- # Use reportlab to make a nice PDF
772
- doc = SimpleDocTemplate(fname, pagesize=A4,
773
- leftMargin=22*10, rightMargin=22*10, topMargin=28*10, bottomMargin=22*10)
774
- styles = getSampleStyleSheet()
775
- title = ParagraphStyle("title", parent=styles["Title"], fontSize=18, textColor=colors.HexColor("#FF7A00"), alignment=1)
776
- body = ParagraphStyle("body", parent=styles["BodyText"], fontSize=11, leading=14)
777
- elems = []
778
- elems.append(Paragraph("GeoMate — Soil Classification Report", title))
779
- elems.append(Spacer(1,12))
780
- elems.append(Paragraph(f"Site: {site.get('Site Name')}", body))
781
- elems.append(Spacer(1,8))
782
- # Inputs table
783
- inputs = site.get("classifier_inputs", {})
784
- rows = [["Parameter", "Value"]]
785
- for k in ["opt","P2","P4","D60","D30","D10","LL","PL","nDS_text","nDIL_text","nTG_text"]:
786
- rows.append([str(k), str(inputs.get(k, ""))])
787
- t = Table(rows, colWidths=[150,300])
788
- t.setStyle(TableStyle([("GRID",(0,0),(-1,-1),0.5,colors.grey),("BACKGROUND",(0,0),(-1,0),colors.HexColor("#1F4E79")),("TEXTCOLOR",(0,0),(-1,0),colors.white)]))
789
- elems.append(t); elems.append(Spacer(1,12))
790
- # Results
791
- elems.append(Paragraph(f"USCS: {site.get('USCS')}", body))
792
- elems.append(Paragraph(f"AASHTO: {site.get('AASHTO')}", body))
793
- elems.append(Paragraph(f"Group Index (GI): {site.get('GI')}", body))
794
- elems.append(Spacer(1,8))
795
- elems.append(Paragraph("Decision path / notes:", body))
796
- elems.append(Paragraph(site.get("classifier_decision_path","Not available"), body))
797
- doc.build(elems)
798
- return fname
799
-
800
- # ---------- Locator Page ----------
801
- def locator_page():
802
- st.markdown("## 🌍 Locator — Mark AOI & fetch data")
803
- idx, site = active_site()
804
- st.markdown("This page helps you mark a location or polygon for the active site.\nIf Earth Engine is available it will be used; otherwise a fallback map is shown.")
805
- # allow user to input lat/lon or draw on a map
806
- lat = st.text_input("Latitude", value=str(site.get("lat","") or ""))
807
- lon = st.text_input("Longitude", value=str(site.get("lon","") or ""))
808
- try:
809
- site["lat"] = float(lat) if lat.strip()!="" else None
810
- site["lon"] = float(lon) if lon.strip()!="" else None
811
- except:
812
- st.error("Lat/Lon must be numeric or blank.")
813
-
814
- # Try Earth Engine map if available & secrets present
815
- if HAVE_EE and ss.secrets_status.get("ee_ok", False):
816
- # initialize EE if not already
817
- try:
818
- ee.Initialize()
819
- st.success("Earth Engine initialized.")
820
- # show a simple map centered on site coords
821
- center = [site.get("lat") or 0.0, site.get("lon") or 0.0]
822
- m = geemap.Map(center=center, zoom=8)
823
- st.markdown("Use the map to navigate. (Geemap/EE is enabled)")
824
- m.to_streamlit(height=500)
825
- except Exception as e:
826
- st.error(f"Earth Engine map failed: {e}")
827
- # fallback to folium
828
- _show_folium_map(site)
829
- else:
830
- # folium fallback
831
- _show_folium_map(site)
832
-
833
- # After user picks/enters AOI, allow fetch data (placeholder)
834
- if st.button("Fetch site data (soil profile, flood, seismic)"):
835
- # In real app: call Earth Engine functions
836
- # For now produce placeholders and save into site
837
- site["Soil Profile"] = "Placeholder soil profile fetched for location."
838
- site["Flood Data"] = "Placeholder: No major flood history in past 20 years (simulated)."
839
- site["Seismic Data"] = "Placeholder: Seismic history fetched (20-year simulated)."
840
- st.success("Data fetched and saved into site record.")
841
-
842
- def _show_folium_map(site):
843
- if not HAVE_FOLIUM:
844
- st.info("Map unavailable (folium not installed). Please add folium & streamlit-folium to requirements.")
845
- return
846
- # show a map with a draw control where user can draw polygon
847
- lat0 = site.get("lat") or 0.0
848
- lon0 = site.get("lon") or 0.0
849
- m = folium.Map(location=[lat0, lon0], zoom_start=6, tiles="OpenStreetMap")
850
- # Add marker for current point if present
851
- if site.get("lat") and site.get("lon"):
852
- folium.Marker(location=[site.get("lat"), site.get("lon")], tooltip="Site location").add_to(m)
853
- # Add draw plugin
854
- try:
855
- from folium.plugins import Draw
856
- Draw(export=True).add_to(m)
857
- except Exception:
858
- pass
859
- st.write("You can draw a polygon with the draw tool, then click Export to copy the GeoJSON and paste into the textbox below.")
860
- st_data = st_folium(m, height=450)
861
- geojson_text = st.text_area("Or paste GeoJSON polygon here (optional):", value=site.get("Site Coordinates") or "", height=120)
862
- if geojson_text and st.button("Save site boundary / coordinates"):
863
- site["Site Coordinates"] = geojson_text
864
- st.success("Saved site geometry.")
865
-
866
- # ---------- GeoMate Ask (RAG) Page ----------
867
- def rag_page():
868
- st.markdown("## 🤖 GeoMate Ask — Technical Chatbot (RAG placeholder)")
869
- idx, site = active_site()
870
- st.markdown("Site-specific chat memory is preserved for this session. The RAG is a placeholder that will call your chosen LLM if GROQ key is set.")
871
- if not ss.secrets_status.get("groq_ok", False):
872
- st.info("GROQ key missing: RAG responses will be simulated locally. Set GROQ_API_KEY to enable full LLM features.")
873
- # display chat history for site
874
- history = site.get("chat_history", [])
875
- for msg in history[-50:]:
876
- if msg.get("role") == "bot":
877
- st.markdown(f"<div class='chat-bot'>🤖 GeoMate: {msg.get('text')}</div>", unsafe_allow_html=True)
878
- else:
879
- st.markdown(f"<div class='chat-user'>👤 {msg.get('text')}</div>", unsafe_allow_html=True)
880
- # chat input
881
- user_msg = st.text_input("Ask GeoMate about this site (technical):", key=f"rag_input_{idx}")
882
- col1, col2 = st.columns([1,3])
883
- with col1:
884
- if st.button("Send"):
885
- if user_msg.strip()=="":
886
- st.warning("Enter a message.")
887
- else:
888
- # append user msg
889
- site.setdefault("chat_history", []).append({"time": now_str(), "role": "user", "text": user_msg})
890
- # simple simulated bot response or call Groq if available
891
- bot_reply = simulated_rag_reply(user_msg, site)
892
- site.setdefault("chat_history", []).append({"time": now_str(), "role": "bot", "text": bot_reply})
893
- st.experimental_rerun()
894
- st.markdown("---")
895
- st.markdown("Tip: If you tell GeoMate a numerical value (e.g. 'bearing capacity is 2500 kPa'), it will try to auto-save that into site record.")
896
- if st.button("Extract parameters from chat history (auto)"):
897
- update_site_description_from_chat(site)
898
- st.success("Attempted to extract parameters from chat history into site record.")
899
-
900
- def simulated_rag_reply(question: str, site: dict) -> str:
901
- """A simple simulated reply or placeholder for calling the LLM API."""
902
- # If GROQ available, you would call the LLM here. For now return a helpful canned reply.
903
- if ss.secrets_status.get("groq_ok", False) and HAVE_GROQ:
904
- try:
905
- client = Groq(api_key=os.environ.get("GROQ_API_KEY"))
906
- # Build a compact prompt (left as placeholder)
907
- prompt = f"Site JSON: {json.dumps(site)}\nUser question: {question}\nAnswer succinctly in a professional tone."
908
- resp = client.chat.completions.create(model=ss.llm_model, messages=[{"role":"system","content":"You are GeoMate AI."},{"role":"user","content":prompt}], temperature=0.2)
909
- return resp.choices[0].message.content
910
- except Exception as e:
911
- return f"(LLM call failed) Simulated reply: I understood your question: '{question}'"
912
- # local simulated behavior:
913
- if "bearing" in question.lower() or "bearing capacity" in question.lower():
914
- return "Based on the site data present, a preliminary allowable bearing capacity could be assumed as 200-250 kPa. For a reliable value, perform plate load or SPT correlations."
915
- if "gcd" in question.lower():
916
- return "Not sure what 'gcd' refers to — please clarify."
917
- return "Thanks — I saved your query. For detailed calculations, enable LLM or provide more site inputs."
918
-
919
- def update_site_description_from_chat(site: dict):
920
- """Scan the recent chat history for numeric engineering parameters and save them into the site dict.
921
- This is a heuristic extractor, not a full NER model.
922
- """
923
- text = " ".join([m.get("text","") for m in site.get("chat_history", [])[-40:]])
924
- # patterns
925
- # bearing capacity
926
- m = re.search(r"(bearing\s*capacity|allowable)\s*(is|=|:)?\s*([0-9]+(?:\.[0-9]+)?)\s*(kpa|kN/m2|kn/m2|psf)?", text, flags=re.I)
927
- if m:
928
- val = float(m.group(3))
929
- site["Load Bearing Capacity"] = f"{val} {m.group(4) or ''}".strip()
930
- # skin shear strength
931
- m2 = re.search(r"(skin\s*shear\s*strength)\s*(is|=|:)?\s*([0-9]+(?:\.[0-9]+)?)\s*(kpa|kn/m2|psf)?", text, flags=re.I)
932
- if m2:
933
- site["Skin Shear Strength"] = f"{float(m2.group(3))} {m2.group(4) or ''}".strip()
934
- # relative compaction
935
- m3 = re.search(r"(relative\s*compaction)\s*(is|=|:)?\s*([0-9]{1,3})\s*%?", text, flags=re.I)
936
- if m3:
937
- site["Relative Compaction"] = f"{m3.group(3)}%"
938
- # Rate of consolidation (text)
939
- m4 = re.search(r"(rate of consolidation)\s*(is|=|:)?\s*([a-zA-Z0-9\s\-]+)", text, flags=re.I)
940
- if m4:
941
- site["Rate of Consolidation"] = m4.group(3).strip()
942
- return
943
-
944
- # ---------- Reports Page (chatbot collects missing params then generate PDF) ----------
945
- # Full parameter bank - all parameters you asked for (a representative comprehensive set)
946
- FULL_PARAM_BANK = [
947
- # Project & site
948
- ("Project Description", "text"),
949
- ("Site Name", "text"),
950
- ("Site Coordinates", "text"),
951
- ("Site Area (ha)", "number"),
952
- ("Topography", "text"),
953
- ("Current Land Use", "text"),
954
- ("Site History", "text"),
955
- # Field investigation
956
- ("Number of Boreholes", "number"),
957
- ("Max Borehole Depth (m)", "number"),
958
- ("SPT N-values (summary)", "text"),
959
- ("Groundwater Depth (m)", "number"),
960
- ("Refusal Depths", "text"),
961
- # Lab tests
962
- ("Liquid Limit (LL)", "number"),
963
- ("Plastic Limit (PL)", "number"),
964
- ("Plasticity Index (PI)", "number"),
965
- ("GSD (D10,D30,D60)", "text"),
966
- ("MDD (kg/m3)", "number"),
967
- ("OMC (%)", "number"),
968
- ("CBR (%)", "number"),
969
- ("UCS (kPa)", "number"),
970
- ("Triaxial results", "text"),
971
- ("Consolidation parameters", "text"),
972
- ("Swell (%)", "number"),
973
- # Design
974
- ("Load Bearing Capacity", "text"),
975
- ("Skin Shear Strength", "text"),
976
- ("Relative Compaction (%)", "number"),
977
- ("Rate of Consolidation", "text"),
978
- ("Nature of Construction", "text"),
979
- # Environmental & hazards
980
- ("Flood history (20 yr)", "text"),
981
- ("Seismic history (20 yr)", "text"),
982
- ("Soil contamination indicators", "text"),
983
- ("Environmental concerns", "text"),
984
- # Others
985
- ("Recommended foundation type", "text"),
986
- ("Pavement subgrade classification", "text"),
987
- ("Notes/Conclusions", "text")
988
- ]
989
-
990
- # Basic param subset (10-15)
991
- BASIC_PARAM_LIST = [
992
- "Site Name","Site Coordinates","Liquid Limit (LL)","Plastic Limit (PL)","GSD (D10,D30,D60)",
993
- "Load Bearing Capacity","Skin Shear Strength","Relative Compaction (%)","Groundwater Depth (m)","Nature of Construction"
994
- ]
995
-
996
- def reports_page():
997
- st.markdown("## 📑 Reports — Basic & Detailed")
998
- idx, site = active_site()
999
- st.markdown("Choose Basic (10-15 parameters) or Detailed (all parameters) report workflows.")
1000
- rep_type = st.radio("Report type", ["Basic (10-15 params)","Detailed (full)"])
1001
- if rep_type.startswith("Basic"):
1002
- param_list = BASIC_PARAM_LIST
1003
- else:
1004
- param_list = [p[0] for p in FULL_PARAM_BANK]
1005
-
1006
- st.markdown("### Chatbot to collect missing parameters (you can Skip any question)")
1007
- # Build per-site report convo state index
1008
- if "report_convo_state" not in site:
1009
- site["report_convo_state"] = 0
1010
- # Build a simple queue of params to ask (only missing ones)
1011
- missing = [p for p in param_list if not site.get(p)]
1012
- st.write(f"Missing parameters to collect: {len(missing)}")
1013
- # show conversation history
1014
- if "report_chat" not in site:
1015
- site["report_chat"] = []
1016
- for msg in site.get("report_chat", []):
1017
- if msg["role"]=="bot":
1018
- st.markdown(f"<div class='chat-bot'>{msg['text']}</div>", unsafe_allow_html=True)
1019
- else:
1020
- st.markdown(f"<div class='chat-user'>{msg['text']}</div>", unsafe_allow_html=True)
1021
-
1022
- # If no missing, show generate button
1023
- if not missing:
1024
- st.success("All required parameters collected for selected report type.")
1025
- if st.button("📥 Generate PDF Report"):
1026
- # call PDF generator
1027
- fname = build_full_report_pdf(idx, report_type=rep_type.split()[0].lower())
1028
- with open(fname, "rb") as f:
1029
- st.download_button("⬇️ Download Report PDF", f, file_name=fname, mime="application/pdf")
1030
- # compare sites option if more than 1 site present
1031
- if len(ss.site_descriptions) > 1:
1032
- if st.checkbox("Enable site comparison (for sites with same Nature of Construction)"):
1033
- # select sites to compare
1034
- choices = [s.get("Site Name") for s in ss.site_descriptions]
1035
- sel = st.multiselect("Select sites to compare", choices, default=choices)
1036
- if len(sel)>=2:
1037
- compare_sites(sel)
1038
- return
1039
-
1040
- # Ask next missing parameter in conversational style
1041
- next_param = missing[0]
1042
- # Bot question style
1043
- q_text = f"For the active site, please provide the value for **{next_param}**. (You may enter 'skip' to leave it blank.)"
1044
- st.markdown(f"<div class='chat-bot'>{q_text}</div>", unsafe_allow_html=True)
1045
- user_ans = st.text_input(f"Answer for {next_param} (or 'skip')", key=f"report_input_{idx}_{site.get('report_convo_state',0)}")
1046
- col1, col2 = st.columns([1,1])
1047
- with col1:
1048
- if st.button("➤ Submit answer"):
1049
- if user_ans.strip().lower() in ["skip","n","no","don't know","dont know","unknown",""]:
1050
- site[next_param] = None
1051
- site.setdefault("report_chat", []).append({"role":"user","text":"skip"})
1052
- site.setdefault("report_chat", []).append({"role":"bot","text":f"Okay — skipped {next_param}."})
1053
- else:
1054
- site[next_param] = user_ans.strip()
1055
- site.setdefault("report_chat", []).append({"role":"user","text":user_ans.strip()})
1056
- site.setdefault("report_chat", []).append({"role":"bot","text":f"Saved {next_param}."})
1057
- # move to next
1058
- site["report_convo_state"] += 1
1059
- st.rerun()
1060
- with col2:
1061
- if st.button("Skip this"):
1062
- site[next_param] = None
1063
- site.setdefault("report_chat", []).append({"role":"user","text":"skip"})
1064
- site.setdefault("report_chat", []).append({"role":"bot","text":f"Okay — skipped {next_param}."})
1065
- site["report_convo_state"] += 1
1066
- st.rerun()
1067
-
1068
- def compare_sites(site_names: List[str]):
1069
- st.markdown("### Site Comparison")
1070
- # gather selected site dicts
1071
- selected = [s for s in ss.site_descriptions if s.get("Site Name") in site_names]
1072
- # check nature of construction equality
1073
- natures = set(s.get("Nature of Construction") for s in selected)
1074
- if len(natures) == 1:
1075
- st.success("All selected sites share the same Nature of Construction — comparative analysis feasible.")
1076
- else:
1077
- st.warning("Sites have different Nature of Construction — comparisons might be limited.")
1078
- # show a comparison table for key parameters
1079
- keys = ["Site Name","Load Bearing Capacity","USCS","AASHTO","GI","CBR (%)","Relative Compaction","Groundwater Depth (m)"]
1080
- data = [keys]
1081
- for s in selected:
1082
- row = [s.get(k,"-") for k in keys]
1083
- data.append(row)
1084
- # display as table
1085
- st.table(data)
1086
-
1087
- # ---------- Full report PDF builder ----------
1088
- def build_full_report_pdf(site_idx: int, report_type: str="basic") -> str:
1089
- site = ss.site_descriptions[site_idx]
1090
- stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
1091
- fname = f"geomate_report_{site.get('Site Name','site')}_{report_type}_{stamp}.pdf"
1092
- if not HAVE_REPORTLAB:
1093
- # fallback text file
1094
- with open(fname, "w") as f:
1095
- f.write(f"GeoMate {report_type.title()} Report for {site.get('Site Name')}\nGenerated: {now_str()}\n\nSite Data:\n")
1096
- for k,v in site.items():
1097
- f.write(f"{k}: {v}\n")
1098
- return fname
1099
-
1100
- doc = SimpleDocTemplate(fname, pagesize=A4,
1101
- leftMargin=22, rightMargin=22, topMargin=28, bottomMargin=22)
1102
- styles = getSampleStyleSheet()
1103
- title_style = ParagraphStyle("Title", parent=styles["Title"], fontSize=18, alignment=1, textColor=colors.HexColor("#FF7A00"))
1104
- h2 = ParagraphStyle("h2", parent=styles["Heading2"], textColor=colors.HexColor("#0f62fe"))
1105
- body = ParagraphStyle("body", parent=styles["BodyText"], fontSize=11, leading=14)
1106
-
1107
- elems = []
1108
- elems.append(Paragraph("GEOTECHNICAL INVESTIGATION REPORT", title_style))
1109
- elems.append(Spacer(1,8))
1110
- # project & site info
1111
- elems.append(Paragraph("Project & Site Information", h2))
1112
- info = [
1113
- ["Site Name", site.get("Site Name","-")],
1114
- ["Coordinates", site.get("Site Coordinates","-")],
1115
- ["Nature of Construction", site.get("Nature of Construction","-")],
1116
- ["Date Generated", now_str()]
1117
- ]
1118
- t = Table(info, colWidths=[140,350])
1119
- t.setStyle(TableStyle([("GRID",(0,0),(-1,-1),0.3,colors.grey),("BACKGROUND",(0,0),(0,-1),colors.whitesmoke)]))
1120
- elems.append(t)
1121
- elems.append(Spacer(1,8))
1122
-
1123
- # classification summary
1124
- elems.append(Paragraph("Classification Summary", h2))
1125
- elems.append(Paragraph(f"USCS: {site.get('USCS','-')}", body))
1126
- elems.append(Paragraph(f"AASHTO: {site.get('AASHTO','-')} (GI: {site.get('GI','-')})", body))
1127
- elems.append(Spacer(1,8))
1128
-
1129
- # field & lab summary (from site keys)
1130
- elems.append(Paragraph("Field & Laboratory Summary", h2))
1131
- lab_rows = []
1132
- # choose a set of keys to show
1133
- keys = ["Liquid Limit (LL)","Plastic Limit (PL)","GSD (D10,D30,D60)","MDD (kg/m3)","OMC (%)","CBR (%)","UCS (kPa)","Consolidation parameters"]
1134
- for k in keys:
1135
- lab_rows.append([k, site.get(k,"-")])
1136
- t2 = Table(lab_rows, colWidths=[200,290])
1137
- t2.setStyle(TableStyle([("GRID",(0,0),(-1,-1),0.3,colors.grey)]))
1138
- elems.append(t2)
1139
-
1140
- elems.append(Spacer(1,12))
1141
- # recommendations stub (could call LLM for detailed text)
1142
- elems.append(Paragraph("Recommendations", h2))
1143
- rec_text = site.get("Notes/Conclusions") or "Based on provided data and classification, GeoMate recommends design by a geotechnical professional; consider shallow foundations where suitable, and further testing where required."
1144
- elems.append(Paragraph(rec_text, body))
1145
- elems.append(Spacer(1,12))
1146
-
1147
- # Append map snapshot if provided
1148
- if site.get("map_snapshot"):
1149
- try:
1150
- elems.append(Paragraph("Site Map Snapshot", h2))
1151
- elems.append(Image(site.get("map_snapshot"), width=400, height=250))
1152
- elems.append(Spacer(1,8))
1153
- except Exception:
1154
- pass
1155
-
1156
- # Append full site JSON as appendix
1157
- elems.append(PageBreak())
1158
- elems.append(Paragraph("Appendix: Full Site Data (JSON)", h2))
1159
- elems.append(Paragraph(json.dumps(site, indent=2), ParagraphStyle("mono", fontName="Courier", fontSize=8)))
1160
- doc.build(elems)
1161
- return fname
1162
-
1163
- # -------------------------
1164
- # Main app layout & routing
1165
- # -------------------------
1166
- def main():
1167
- sidebar_ui()
1168
- page = st.sidebar.radio("Pages", ["Landing","Soil Classifier","Locator","GeoMate Ask","Reports"], index=0)
1169
- # show top bar with active model and site
1170
- st.markdown(f"<div style='display:flex;align-items:center;justify-content:space-between'>"
1171
- f"<div><small class='muted'>Model: {ss.llm_model} • Active site: {active_site()[1].get('Site Name')}</small></div>"
1172
- f"<div><small class='muted'>Session started: {ss.get('session_start', now_str())}</small></div></div>", unsafe_allow_html=True)
1173
-
1174
- if page == "Landing":
1175
- landing_page()
1176
- elif page == "Soil Classifier":
1177
- soil_classifier_page()
1178
- elif page == "Locator":
1179
- locator_page()
1180
- elif page == "GeoMate Ask":
1181
- rag_page()
1182
- elif page == "Reports":
1183
- reports_page()
1184
- else:
1185
- landing_page()
1186
-
1187
- if __name__ == "__main__":
1188
- # initialize session start time
1189
- if "session_start" not in ss:
1190
- ss["session_start"] = now_str()
1191
- main()