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

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +525 -0
app.py ADDED
@@ -0,0 +1,525 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py — GeoMate V2 (single-file Streamlit app)
2
+ # ------------------------------------------------
3
+ # Paste this entire file into your Hugging Face Space as app.py
4
+ # ------------------------------------------------
5
+
6
+ from __future__ import annotations
7
+ import os
8
+ import io
9
+ import json
10
+ import math
11
+ from math import floor
12
+ from datetime import datetime
13
+ from typing import Any, Dict, List, Tuple, Optional
14
+
15
+ # Streamlit must be imported early and set_page_config must be first Streamlit command
16
+ import streamlit as st
17
+
18
+ # Page config (first Streamlit call)
19
+ st.set_page_config(page_title="GeoMate V2", page_icon="🌍", layout="wide", initial_sidebar_state="expanded")
20
+
21
+ # Standard libs for data & plotting
22
+ import numpy as np
23
+ import pandas as pd
24
+ import matplotlib.pyplot as plt
25
+ import traceback
26
+
27
+ # Optional heavy libs — import safely
28
+ try:
29
+ import faiss # may fail in some environments
30
+ except Exception:
31
+ faiss = None
32
+
33
+ try:
34
+ import reportlab
35
+ from reportlab.lib import colors
36
+ from reportlab.lib.pagesizes import A4
37
+ from reportlab.lib.units import mm
38
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak
39
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
40
+ REPORTLAB_OK = True
41
+ except Exception:
42
+ REPORTLAB_OK = False
43
+
44
+ # FPDF fallback for simple PDFs
45
+ try:
46
+ from fpdf import FPDF
47
+ FPDF_OK = True
48
+ except Exception:
49
+ FPDF_OK = False
50
+
51
+ # Groq client optional
52
+ try:
53
+ from groq import Groq
54
+ GROQ_OK = True
55
+ except Exception:
56
+ GROQ_OK = False
57
+
58
+ # Earth Engine & geemap optional
59
+ try:
60
+ import ee
61
+ import geemap
62
+ EE_OK = True
63
+ except Exception:
64
+ ee = None
65
+ geemap = None
66
+ EE_OK = False
67
+
68
+ # OCR optional (pytesseract / easyocr)
69
+ OCR_TESSERACT = False
70
+ try:
71
+ import pytesseract
72
+ from PIL import Image
73
+ OCR_TESSERACT = True
74
+ except Exception:
75
+ OCR_TESSERACT = False
76
+
77
+ # Helpful alias for session state
78
+ ss = st.session_state
79
+
80
+ # -------------------------
81
+ # Helper: safe rerun
82
+ # -------------------------
83
+ def safe_rerun():
84
+ """Try to rerun app. Prefer st.rerun(); fallback to experimental or simple stop."""
85
+ try:
86
+ st.rerun()
87
+ except Exception:
88
+ try:
89
+ st.experimental_rerun()
90
+ except Exception:
91
+ # last resort: stop execution so user can click/refresh
92
+ st.stop()
93
+
94
+ # -------------------------
95
+ # Secrets: HF-friendly access
96
+ # -------------------------
97
+ def get_secret(name: str) -> Optional[str]:
98
+ """Read secrets from environment variables first, then streamlit secrets."""
99
+ v = os.environ.get(name)
100
+ if v:
101
+ return v
102
+ try:
103
+ v2 = st.secrets.get(name)
104
+ if v2:
105
+ # st.secrets may contain nested dicts (for JSON keys)
106
+ if isinstance(v2, dict) or isinstance(v2, list):
107
+ # convert to JSON string
108
+ return json.dumps(v2)
109
+ return str(v2)
110
+ except Exception:
111
+ pass
112
+ return None
113
+
114
+ # Required secret names (user asked)
115
+ GROQ_KEY = get_secret("GROQ_API_KEY")
116
+ SERVICE_ACCOUNT = get_secret("SERVICE_ACCOUNT")
117
+ EARTH_ENGINE_KEY = get_secret("EARTH_ENGINE_KEY") # JSON content (string) or path; optional
118
+ # We'll consider them optional; pages will show clear errors if missing
119
+ HAVE_GROQ = bool(GROQ_KEY and GROQ_OK)
120
+ HAVE_SERVICE_ACCOUNT = bool(SERVICE_ACCOUNT)
121
+ HAVE_EE_KEY = bool(EARTH_ENGINE_KEY)
122
+ # EE readiness flag we'll set after attempted init
123
+ EE_READY = False
124
+
125
+ # Attempt to initialize Earth Engine if credentials provided and ee module available
126
+ if EE_OK and (SERVICE_ACCOUNT or EARTH_ENGINE_KEY):
127
+ try:
128
+ # If EARTH_ENGINE_KEY is a JSON string, write to temp file and use service account auth
129
+ key_file = None
130
+ if EARTH_ENGINE_KEY:
131
+ # attempt to parse as json content
132
+ try:
133
+ parsed = json.loads(EARTH_ENGINE_KEY)
134
+ # write to /tmp/geomate_ee_key.json
135
+ key_file = "/tmp/geomate_ee_key.json"
136
+ with open(key_file, "w") as f:
137
+ json.dump(parsed, f)
138
+ except Exception:
139
+ # maybe EARTH_ENGINE_KEY is a path already on disk (unlikely on HF)
140
+ key_file = EARTH_ENGINE_KEY if os.path.exists(EARTH_ENGINE_KEY) else None
141
+ if key_file and SERVICE_ACCOUNT:
142
+ try:
143
+ # Use oauth2client if available
144
+ from oauth2client.service_account import ServiceAccountCredentials
145
+ creds = ServiceAccountCredentials.from_json_keyfile_name(key_file, scopes=['https://www.googleapis.com/auth/earthengine'])
146
+ ee.Initialize(creds)
147
+ EE_READY = True
148
+ except Exception:
149
+ # fallback: try ee.Initialize with service_account
150
+ try:
151
+ # This call may fail depending on ee versions; keep safe
152
+ ee.Initialize()
153
+ EE_READY = True
154
+ except Exception:
155
+ EE_READY = False
156
+ else:
157
+ # try simple ee.Initialize()
158
+ try:
159
+ ee.Initialize()
160
+ EE_READY = True
161
+ except Exception:
162
+ EE_READY = False
163
+ except Exception:
164
+ EE_READY = False
165
+ else:
166
+ EE_READY = False
167
+
168
+ # -------------------------
169
+ # Session state initialization
170
+ # -------------------------
171
+ # site_descriptions: list of dicts (max 4). We'll store as list for site ordering but also index by name.
172
+ if "site_descriptions" not in ss:
173
+ # default single site
174
+ ss["site_descriptions"] = []
175
+ if "active_site_index" not in ss:
176
+ ss["active_site_index"] = 0
177
+ if "llm_model" not in ss:
178
+ ss["llm_model"] = "llama3-8b-8192"
179
+ if "page" not in ss:
180
+ ss["page"] = "Landing"
181
+ if "rag_memory" not in ss:
182
+ ss["rag_memory"] = {} # per-site chat memory
183
+ if "classifier_states" not in ss:
184
+ ss["classifier_states"] = {} # per-site classifier step & inputs
185
+
186
+ # Utility: create default site structure
187
+ def make_empty_site(name: str = "Site 1") -> dict:
188
+ return {
189
+ "Site Name": name,
190
+ "Site Coordinates": "",
191
+ "lat": None,
192
+ "lon": None,
193
+ "Load Bearing Capacity": None,
194
+ "Skin Shear Strength": None,
195
+ "Relative Compaction": None,
196
+ "Rate of Consolidation": None,
197
+ "Nature of Construction": None,
198
+ "Soil Profile": None,
199
+ "Flood Data": None,
200
+ "Seismic Data": None,
201
+ "GSD": None,
202
+ "USCS": None,
203
+ "AASHTO": None,
204
+ "GI": None,
205
+ "classifier_inputs": {},
206
+ "classifier_decision_path": "",
207
+ "chat_history": [],
208
+ "report_convo_state": 0,
209
+ "map_snapshot": None,
210
+ "classifier_state": 0,
211
+ "classifier_chat": []
212
+ }
213
+
214
+ # ensure at least one site exists
215
+ if len(ss["site_descriptions"]) == 0:
216
+ ss["site_descriptions"].append(make_empty_site("Home"))
217
+
218
+ # -------------------------
219
+ # Sidebar: site management & LLM model selection
220
+ # -------------------------
221
+ from streamlit_option_menu import option_menu
222
+
223
+ def sidebar_ui():
224
+ st.sidebar.markdown("<div style='text-align:center'><h2 style='color:#FF8C00;margin:6px 0'>GeoMate V2</h2></div>", unsafe_allow_html=True)
225
+ st.sidebar.markdown("---")
226
+
227
+ # Model selector (persist)
228
+ st.sidebar.subheader("LLM Model")
229
+ model_options = ["llama3-8b-8192", "gemma-7b-it", "mixtral-8x7b-32768"]
230
+ ss["llm_model"] = st.sidebar.selectbox("Select LLM model", model_options, index=model_options.index(ss.get("llm_model","llama3-8b-8192")), key="llm_select")
231
+
232
+ st.sidebar.markdown("---")
233
+ st.sidebar.subheader("Project Sites (max 4)")
234
+ # show sites and allow add/remove, choose active site
235
+ colA, colB = st.sidebar.columns([2,1])
236
+ with colA:
237
+ new_site_name = st.text_input("New site name", value="", key="new_site_name_input")
238
+ with colB:
239
+ if st.button("➕", key="add_site_btn"):
240
+ # add new site up to 4
241
+ if len(ss["site_descriptions"]) >= 4:
242
+ st.sidebar.warning("Maximum 4 sites allowed.")
243
+ else:
244
+ name = new_site_name.strip() or f"Site {len(ss['site_descriptions'])+1}"
245
+ ss["site_descriptions"].append(make_empty_site(name))
246
+ ss["active_site_index"] = len(ss["site_descriptions"]) - 1
247
+ safe_rerun()
248
+
249
+ # list of site names
250
+ site_names = [s["Site Name"] for s in ss["site_descriptions"]]
251
+ idx = st.sidebar.radio("Active Site", options=list(range(len(site_names))), format_func=lambda i: site_names[i], index=ss.get("active_site_index",0), key="active_site_radio")
252
+ ss["active_site_index"] = idx
253
+
254
+ # Remove site
255
+ if st.sidebar.button("🗑️ Remove Active Site", key="remove_site_btn"):
256
+ if len(ss["site_descriptions"]) <= 1:
257
+ st.sidebar.warning("Cannot remove last site.")
258
+ else:
259
+ ss["site_descriptions"].pop(ss["active_site_index"])
260
+ ss["active_site_index"] = max(0, ss["active_site_index"] - 1)
261
+ safe_rerun()
262
+
263
+ st.sidebar.markdown("---")
264
+ st.sidebar.subheader("Active Site JSON")
265
+ with st.sidebar.expander("Show site JSON", expanded=False):
266
+ st.code(json.dumps(ss["site_descriptions"][ss["active_site_index"]], indent=2), language="json")
267
+
268
+ st.sidebar.markdown("---")
269
+ # Secrets indicator (not blocking)
270
+ st.sidebar.subheader("Service Status")
271
+ col1, col2 = st.sidebar.columns(2)
272
+ col1.markdown("LLM:")
273
+ col2.markdown("✅" if HAVE_GROQ else "⚠️ (no Groq)")
274
+ col1.markdown("Earth Engine:")
275
+ col2.markdown("✅" if EE_READY else "⚠️ (not initialized)")
276
+ st.sidebar.markdown("---")
277
+ # Navigation menu
278
+ pages = ["Landing", "Soil Recognizer", "Soil Classifier", "GSD Curve", "Locator", "GeoMate Ask", "Reports"]
279
+ icons = ["house", "image", "flask", "bar-chart", "geo-alt", "robot", "file-earmark-text"]
280
+ choice = option_menu(None, pages, icons=icons, menu_icon="cast", default_index=pages.index(ss.get("page","Landing")), orientation="vertical", styles={
281
+ "container": {"padding": "0px"},
282
+ "nav-link-selected": {"background-color": "#FF7A00"},
283
+ })
284
+ if choice and choice != ss.get("page"):
285
+ ss["page"] = choice
286
+ safe_rerun()
287
+
288
+ # -------------------------
289
+ # Landing UI
290
+ # -------------------------
291
+ def landing_ui():
292
+ st.markdown(
293
+ """
294
+ <style>
295
+ .hero {
296
+ background: linear-gradient(135deg,#0f0f0f 0%, #060606 100%);
297
+ border-radius: 14px;
298
+ padding: 20px;
299
+ border: 1px solid rgba(255,122,0,0.08);
300
+ }
301
+ .glow-btn {
302
+ background: linear-gradient(90deg,#ff7a00,#ff3a3a);
303
+ color: white;
304
+ padding: 10px 18px;
305
+ border-radius: 10px;
306
+ font-weight:700;
307
+ box-shadow: 0 6px 24px rgba(255,122,0,0.12);
308
+ border: none;
309
+ }
310
+ </style>
311
+ """, unsafe_allow_html=True)
312
+ st.markdown("<div class='hero'>", unsafe_allow_html=True)
313
+ st.markdown("<h1 style='color:#FF8C00;margin:0'>🌍 GeoMate V2</h1>", unsafe_allow_html=True)
314
+ st.markdown("<p style='color:#ddd'>AI copilot for geotechnical engineering — soil recognition, classification, locator, RAG, professional reports.</p>", unsafe_allow_html=True)
315
+ st.markdown("</div>", unsafe_allow_html=True)
316
+ st.markdown("---")
317
+ st.write("Quick actions")
318
+ c1, c2, c3 = st.columns(3)
319
+ if c1.button("🖼️ Soil Recognizer"):
320
+ ss["page"] = "Soil Recognizer"; safe_rerun()
321
+ if c2.button("🧪 Soil Classifier"):
322
+ ss["page"] = "Soil Classifier"; safe_rerun()
323
+ if c3.button("📊 GSD Curve"):
324
+ ss["page"] = "GSD Curve"; safe_rerun()
325
+ c4, c5, c6 = st.columns(3)
326
+ if c4.button("🌍 Locator"):
327
+ ss["page"] = "Locator"; safe_rerun()
328
+ if c5.button("🤖 GeoMate Ask"):
329
+ ss["page"] = "GeoMate Ask"; safe_rerun()
330
+ if c6.button("📑 Reports"):
331
+ ss["page"] = "Reports"; safe_rerun()
332
+
333
+ # -------------------------
334
+ # Utility: active site helpers
335
+ # -------------------------
336
+ def active_site() -> Tuple[str, dict]:
337
+ idx = ss.get("active_site_index", 0)
338
+ idx = max(0, min(idx, len(ss["site_descriptions"]) - 1))
339
+ ss["active_site_index"] = idx
340
+ site = ss["site_descriptions"][idx]
341
+ return idx, site
342
+
343
+ def save_site_field(field: str, value: Any):
344
+ idx, site = active_site()
345
+ site[field] = value
346
+ ss["site_descriptions"][idx] = site
347
+
348
+ # -------------------------
349
+ # USCS & AASHTO verbatim logic (function)
350
+ # -------------------------
351
+ # Uses exact logic from your script and mapping of descriptor strings to numbers
352
+ def uscs_aashto_from_inputs(inputs: Dict[str,Any]) -> Tuple[str,str,str,int,Dict[str,str]]:
353
+ """
354
+ Return: (result_text, uscs_symbol, aashto_symbol, GI, char_summary)
355
+ """
356
+ # Engineering characteristics dictionary (detailed-ish)
357
+ ENGINEERING_CHARACTERISTICS = {
358
+ "Gravel": {
359
+ "Settlement": "None",
360
+ "Quicksand": "Impossible",
361
+ "Frost-heaving": "None",
362
+ "Groundwater_lowering": "Possible",
363
+ "Cement_grouting": "Possible",
364
+ "Silicate_bitumen_injections": "Unsuitable",
365
+ "Compressed_air": "Possible"
366
+ },
367
+ "Coarse sand": {"Settlement":"None","Quicksand":"Impossible","Frost-heaving":"None"},
368
+ "Medium sand": {"Settlement":"None","Quicksand":"Unlikely"},
369
+ "Fine sand": {"Settlement":"None","Quicksand":"Liable"},
370
+ "Silt": {"Settlement":"Occurs","Quicksand":"Liable","Frost-heaving":"Occurs"},
371
+ "Clay": {"Settlement":"Occurs","Quicksand":"Impossible"}
372
+ }
373
+
374
+ opt = str(inputs.get("opt","n")).lower()
375
+ if opt == 'y':
376
+ uscs = "Pt"
377
+ uscs_expl = "Peat / organic soil — compressible, high organic content; poor engineering properties for load-bearing without special treatment."
378
+ aashto = "Organic (special handling)"
379
+ GI = 0
380
+ result_text = f"According to USCS, the soil is {uscs} — {uscs_expl}\nAccording to AASHTO, the soil is {aashto}."
381
+ return result_text, uscs, aashto, GI, {"summary":"Organic peat: large settlement, low strength."}
382
+
383
+ # read numeric inputs safely
384
+ P2 = float(inputs.get("P2", 0.0))
385
+ P4 = float(inputs.get("P4", 0.0))
386
+ D60 = float(inputs.get("D60", 0.0))
387
+ D30 = float(inputs.get("D30", 0.0))
388
+ D10 = float(inputs.get("D10", 0.0))
389
+ LL = float(inputs.get("LL", 0.0))
390
+ PL = float(inputs.get("PL", 0.0))
391
+ PI = LL - PL
392
+
393
+ Cu = (D60 / D10) if (D10 > 0 and D60 > 0) else 0
394
+ Cc = ((D30 ** 2) / (D10 * D60)) if (D10 > 0 and D30 > 0 and D60 > 0) else 0
395
+
396
+ uscs = "Unknown"; uscs_expl = ""
397
+ if P2 <= 50:
398
+ # Coarse-Grained
399
+ if P4 <= 50:
400
+ # Gravels
401
+ if Cu and Cc:
402
+ if Cu >= 4 and 1 <= Cc <= 3:
403
+ uscs, uscs_expl = "GW", "Well-graded gravel (good engineering properties, high strength, good drainage)."
404
+ else:
405
+ uscs, uscs_expl = "GP", "Poorly-graded gravel (less favorable gradation)."
406
+ else:
407
+ if PI < 4 or PI < 0.73 * (LL - 20):
408
+ uscs, uscs_expl = "GM", "Silty gravel (fines may reduce permeability and strength)."
409
+ elif PI > 7 and PI > 0.73 * (LL - 20):
410
+ uscs, uscs_expl = "GC", "Clayey gravel (higher plasticity)."
411
+ else:
412
+ uscs, uscs_expl = "GM-GC", "Gravel with mixed silt/clay fines."
413
+ else:
414
+ # Sands
415
+ if Cu and Cc:
416
+ if Cu >= 6 and 1 <= Cc <= 3:
417
+ uscs, uscs_expl = "SW", "Well-graded sand (good compaction and drainage)."
418
+ else:
419
+ uscs, uscs_expl = "SP", "Poorly-graded sand (uniform or gap-graded)."
420
+ else:
421
+ if PI < 4 or PI <= 0.73 * (LL - 20):
422
+ uscs, uscs_expl = "SM", "Silty sand (low-plasticity fines)."
423
+ elif PI > 7 and PI > 0.73 * (LL - 20):
424
+ uscs, uscs_expl = "SC", "Clayey sand (clayey fines present)."
425
+ else:
426
+ uscs, uscs_expl = "SM-SC", "Transition between silty sand and clayey sand."
427
+ else:
428
+ # Fine-grained soils
429
+ nDS = int(inputs.get("nDS", 5))
430
+ nDIL = int(inputs.get("nDIL", 6))
431
+ nTG = int(inputs.get("nTG", 6))
432
+ if LL < 50:
433
+ if 20 <= LL < 50 and PI <= 0.73 * (LL - 20):
434
+ if nDS == 1 or nDIL == 3 or nTG == 3:
435
+ uscs, uscs_expl = "ML", "Silt (low plasticity)."
436
+ elif nDS == 3 or nDIL == 3 or nTG == 3:
437
+ uscs, uscs_expl = "OL", "Organic silt (low plasticity)."
438
+ else:
439
+ uscs, uscs_expl = "ML-OL", "Mixed silt/organic silt."
440
+ elif 10 <= LL <= 30 and 4 <= PI <= 7 and PI > 0.72 * (LL - 20):
441
+ if nDS == 1 or nDIL == 1 or nTG == 1:
442
+ uscs, uscs_expl = "ML", "Silt."
443
+ elif nDS == 2 or nDIL == 2 or nTG == 2:
444
+ uscs, uscs_expl = "CL", "Clay (low plasticity)."
445
+ else:
446
+ uscs, uscs_expl = "ML-CL", "Mixed silt/clay."
447
+ else:
448
+ uscs, uscs_expl = "CL", "Clay (low plasticity)."
449
+ else:
450
+ if PI < 0.73 * (LL - 20):
451
+ if nDS == 3 or nDIL == 4 or nTG == 4:
452
+ uscs, uscs_expl = "MH", "Silt (high plasticity)"
453
+ elif nDS == 2 or nDIL == 2 or nTG == 4:
454
+ uscs, uscs_expl = "OH", "Organic silt/clay (high plasticity)"
455
+ else:
456
+ uscs, uscs_expl = "MH-OH", "Mixed high-plasticity silt/organic"
457
+ else:
458
+ uscs, uscs_expl = "CH", "Clay (high plasticity)"
459
+
460
+ # AASHTO logic
461
+ if P2 <= 35:
462
+ if P2 <= 15 and P4 <= 30 and PI <= 6:
463
+ aashto = "A-1-a"
464
+ elif P2 <= 25 and P4 <= 50 and PI <= 6:
465
+ aashto = "A-1-b"
466
+ elif P2 <= 35 and P4 > 0:
467
+ if LL <= 40 and PI <= 10:
468
+ aashto = "A-2-4"
469
+ elif LL >= 41 and PI <= 10:
470
+ aashto = "A-2-5"
471
+ elif LL <= 40 and PI >= 11:
472
+ aashto = "A-2-6"
473
+ elif LL >= 41 and PI >= 11:
474
+ aashto = "A-2-7"
475
+ else:
476
+ aashto = "A-2"
477
+ else:
478
+ aashto = "A-3"
479
+ else:
480
+ if LL <= 40 and PI <= 10:
481
+ aashto = "A-4"
482
+ elif LL >= 41 and PI <= 10:
483
+ aashto = "A-5"
484
+ elif LL <= 40 and PI >= 11:
485
+ aashto = "A-6"
486
+ else:
487
+ aashto = "A-7-5" if PI <= (LL - 30) else "A-7-6"
488
+
489
+ # Group Index (GI)
490
+ a = P2 - 35
491
+ a = 0 if a < 0 else (40 if a > 40 else a)
492
+ b = P2 - 15
493
+ b = 0 if b < 0 else (40 if b > 40 else b)
494
+ c = LL - 40
495
+ c = 0 if c < 0 else (20 if c > 20 else c)
496
+ d = PI - 10
497
+ d = 0 if d < 0 else (20 if d > 20 else d)
498
+ GI = floor(0.2 * a + 0.005 * a * c + 0.01 * b * d)
499
+
500
+ aashto_expl = f"{aashto} (GI = {GI})"
501
+
502
+ # Choose characteristic summary based on USCS family
503
+ char_summary = ENGINEERING_CHARACTERISTICS.get("Silt", {})
504
+ if uscs.startswith(("G", "S")):
505
+ char_summary = ENGINEERING_CHARACTERISTICS.get("Coarse sand", ENGINEERING_CHARACTERISTICS.get("Gravel"))
506
+ if uscs.startswith(("M", "C", "O", "H")):
507
+ char_summary = ENGINEERING_CHARACTERISTICS.get("Silt", {})
508
+
509
+ result_text = f"According to USCS, the soil is **{uscs}** — {uscs_expl}\n\nAccording to AASHTO, the soil is **{aashto_expl}**\n\nEngineering characteristics summary:\n"
510
+ for k, v in char_summary.items():
511
+ result_text += f"- {k}: {v}\n"
512
+
513
+ return result_text, uscs, aashto, GI, char_summary
514
+
515
+ # -------------------------
516
+ # GSD Curve page (separate)
517
+ # -------------------------
518
+ def gsd_curve_ui():
519
+ st.header("📊 Grain Size Distribution (GSD) Curve")
520
+ _, site = active_site()
521
+ st.markdown(f"**Active site:** {site['Site Name']}")
522
+
523
+ st.info("Upload a CSV (two columns: diameter_mm, percent_passing) or enter diameters and % passing manually. I will compute D10, D30, D60 and save them for the active site.")
524
+
525
+ col_up, col_manual = st.col