MSU576 commited on
Commit
74d7aaa
Β·
verified Β·
1 Parent(s): 434d1a1

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +746 -1
app.py CHANGED
@@ -139,7 +139,752 @@ def soil_recognizer_page():
139
  # 2. Soil Classifier (OCR + USCS + AASHTO)
140
  def soil_classifier_page():
141
  st.header("πŸ“Š Soil Classifier (USCS + AASHTO)")
142
- # TODO: implement OCR β†’ auto-fill β†’ chatbot style cla
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  pass
144
 
145
 
 
139
  # 2. Soil Classifier (OCR + USCS + AASHTO)
140
  def soil_classifier_page():
141
  st.header("πŸ“Š Soil Classifier (USCS + AASHTO)")
142
+ # ----------------------------
143
+ # Verbose USCS + AASHTO classifier + LLM report + PDF export
144
+ # Drop this into your app.py and call soil_classifier_page() from your navigation
145
+ # ----------------------------
146
+ import re
147
+ import io
148
+ import json
149
+ from math import floor
150
+ from typing import Dict, Any, Tuple
151
+ from PIL import Image
152
+ import pytesseract
153
+ import requests
154
+ import streamlit as st
155
+
156
+ # reportlab for PDF
157
+ from reportlab.lib.pagesizes import A4
158
+ from reportlab.lib.units import mm
159
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image as RLImage, PageBreak
160
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
161
+ from reportlab.lib import colors
162
+
163
+ # ----------------------------
164
+ # Helpers to access site memory - adapt if your app uses different helpers
165
+ # ----------------------------
166
+ def get_active_site():
167
+ idx = st.session_state.get("active_site_idx", 0)
168
+ sites = st.session_state.get("sites", [])
169
+ if 0 <= idx < len(sites):
170
+ return sites[idx]
171
+ # if none, create default
172
+ if sites == []:
173
+ st.session_state["sites"] = [{"Site Name": "Site 1"}]
174
+ st.session_state["active_site_idx"] = 0
175
+ return st.session_state["sites"][0]
176
+ return None
177
+
178
+ def save_active_site(site: dict):
179
+ idx = st.session_state.get("active_site_idx", 0)
180
+ st.session_state["sites"][idx] = site
181
+ st.session_state.modified = True
182
+
183
+ # ----------------------------
184
+ # Utility for numeric input retrieval (flexible key names)
185
+ # ----------------------------
186
+ def _readf(inputs: Dict[str,Any], *keys, default: float = 0.0) -> float:
187
+ for k in keys:
188
+ if k in inputs and inputs[k] is not None and inputs[k] != "":
189
+ try:
190
+ return float(inputs[k])
191
+ except Exception:
192
+ try:
193
+ return float(str(inputs[k]).replace("%","").strip())
194
+ except Exception:
195
+ pass
196
+ return default
197
+
198
+ # ----------------------------
199
+ # AASHTO: verbatim logic from your supplied script
200
+ # ----------------------------
201
+ def classify_aashto_verbatim(inputs: Dict[str,Any]) -> Tuple[str, str, int, str]:
202
+ """
203
+ Returns (ResultCode_str, description_str, GI_int, decision_path_str)
204
+ Inputs keys expected:
205
+ - P200 or P2 : percent passing sieve no.200
206
+ - P4 : percent passing sieve no.40 (your script uses 'P4' labelled that way)
207
+ - P10 or P1 : percent passing sieve no.10 (optional)
208
+ - LL, PL
209
+ """
210
+ P2 = _readf(inputs, "P200", "P2")
211
+ P4 = _readf(inputs, "P40", "P4") # accept P40 or P4
212
+ LL = _readf(inputs, "LL")
213
+ PL = _readf(inputs, "PL")
214
+ PI = LL - PL
215
+ decision = []
216
+ def note(s): decision.append(s)
217
+
218
+ note(f"Input AASHTO: P2={P2}, P4={P4}, LL={LL}, PL={PL}, PI={PI}")
219
+
220
+ Result = None
221
+ desc = ""
222
+
223
+ # Granular Materials
224
+ if P2 <= 35:
225
+ note("P2 <= 35% β†’ Granular branch")
226
+ if (P2 <= 15) and (P4 <= 30) and (PI <= 6):
227
+ note("Condition matched: P2<=15 and P4<=30 and PI<=6 β†’ need P10 to decide A-1-a")
228
+ P1 = _readf(inputs, "P10", "P1")
229
+ if P1 == 0:
230
+ # Can't complete without P1; return note
231
+ note("P10 not provided; cannot fully decide A-1-a. Returning tentative 'A-1-a(?)'")
232
+ return "A-1-a(?)", "Candidate A-1-a (P10 missing).", 0, " -> ".join(decision)
233
+ else:
234
+ if P1 <= 50:
235
+ Result = "A-1-a"
236
+ desc = "Granular material with very good quality (A-1-a)."
237
+ note("P10 <= 50 -> A-1-a")
238
+ else:
239
+ note("P10 > 50 -> inconsistent for A-1-a -> input check required")
240
+ return "ERROR", "Inconsistent inputs for A-1-a (P10 > 50).", 0, " -> ".join(decision)
241
+ elif (P2 <= 25) and (P4 <= 50) and (PI <= 6):
242
+ Result = "A-1-b"
243
+ desc = "Granular material (A-1-b)."
244
+ note("P2 <= 25 and P4 <= 50 and PI <= 6 -> A-1-b")
245
+ elif (P2 <= 35) and (P4 > 0):
246
+ note("P2 <= 35 and P4 > 0 -> A-2 family branch")
247
+ if LL <= 40 and PI <= 10:
248
+ Result = "A-2-4"
249
+ desc = "A-2-4: granular material with silt-like fines."
250
+ note("LL <= 40 and PI <= 10 -> A-2-4")
251
+ elif LL >= 41 and PI <= 10:
252
+ Result = "A-2-5"
253
+ desc = "A-2-5: granular with higher LL fines."
254
+ note("LL >= 41 and PI <= 10 -> A-2-5")
255
+ elif LL <= 40 and PI >= 11:
256
+ Result = "A-2-6"
257
+ desc = "A-2-6: granular with clay-like fines."
258
+ note("LL <= 40 and PI >= 11 -> A-2-6")
259
+ elif LL >= 41 and PI >= 11:
260
+ Result = "A-2-7"
261
+ desc = "A-2-7: granular with high plasticity fines."
262
+ note("LL >= 41 and PI >= 11 -> A-2-7")
263
+ else:
264
+ Result = "A-2-?"
265
+ desc = "A-2 family ambiguous - needs more data."
266
+ note("A-2 branch ambigous.")
267
+ else:
268
+ Result = "A-3"
269
+ desc = "A-3: clean sand."
270
+ note("Else -> A-3 (clean sands)")
271
+ else:
272
+ # Silt-Clay Materials
273
+ note("P2 > 35% -> Fine (silt/clay) branch")
274
+ if LL <= 40 and PI <= 10:
275
+ Result = "A-4"
276
+ desc = "A-4: silt of low LL/PI."
277
+ note("LL <= 40 and PI <= 10 -> A-4")
278
+ elif LL >= 41 and PI <= 10:
279
+ Result = "A-5"
280
+ desc = "A-5: elastic silt (higher LL but low PI)."
281
+ note("LL >= 41 and PI <= 10 -> A-5")
282
+ elif LL <= 40 and PI >= 11:
283
+ Result = "A-6"
284
+ desc = "A-6: clay of low LL and higher PI."
285
+ note("LL <= 40 and PI >= 11 -> A-6")
286
+ else:
287
+ # final A-7 determination
288
+ if PI <= (LL - 30):
289
+ Result = "A-7-5"
290
+ desc = "A-7-5: clay of intermediate plasticity."
291
+ note("PI <= (LL - 30) -> A-7-5")
292
+ elif PI > (LL - 30):
293
+ Result = "A-7-6"
294
+ desc = "A-7-6: clay of relatively higher plasticity."
295
+ note("PI > (LL - 30) -> A-7-6")
296
+ else:
297
+ Result = "ERROR"
298
+ desc = "Ambiguous A-7 branch."
299
+ note("AASHTO A-7 branch ambiguous")
300
+
301
+ # --- Group Index (GI) calculation verbatim from your snippet ---
302
+ a = P2 - 35
303
+ if a <= 40 and a >= 0:
304
+ a_val = a
305
+ elif a < 0:
306
+ a_val = 0
307
+ else:
308
+ a_val = 40
309
+
310
+ b = P2 - 15
311
+ if b <= 40 and b >= 0:
312
+ b_val = b
313
+ elif b < 0:
314
+ b_val = 0
315
+ else:
316
+ b_val = 40
317
+
318
+ c = LL - 40
319
+ if c <= 20 and c >= 0:
320
+ c_val = c
321
+ elif c < 0:
322
+ c_val = 0
323
+ else:
324
+ c_val = 20
325
+
326
+ d = PI - 10
327
+ if d <= 20 and d >= 0:
328
+ d_val = d
329
+ elif d < 0:
330
+ d_val = 0
331
+ else:
332
+ d_val = 20
333
+
334
+ GI = floor(0.2 * a_val + 0.005 * a_val * c_val + 0.01 * b_val * d_val)
335
+ note(f"GI compute -> a={a_val}, b={b_val}, c={c_val}, d={d_val}, GI={GI}")
336
+
337
+ decision_path = " -> ".join(decision)
338
+ 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 (?)")
339
+ return full_code, desc, GI, decision_path
340
+
341
+ # ----------------------------
342
+ # USCS: verbatim logic from your supplied script
343
+ # ----------------------------
344
+ def classify_uscs_verbatim(inputs: Dict[str,Any]) -> Tuple[str, str, str]:
345
+ """
346
+ Returns (USCS_code_str, description_str, decision_path_str)
347
+ Accepts inputs:
348
+ - organic (bool or 'y'/'n')
349
+ - P200 / P2 percent passing #200
350
+ - P4 : percent passing sieve no.4 (4.75 mm)
351
+ - D60, D30, D10 (mm)
352
+ - LL, PL
353
+ - nDS, nDIL, nTG options for fines behaviour (integers)
354
+ Implementation follows your original code's branches exactly.
355
+ """
356
+ decision = []
357
+ def note(s): decision.append(s)
358
+
359
+ organic = inputs.get("organic", False)
360
+ if isinstance(organic, str):
361
+ organic = organic.lower() in ("y","yes","true","1")
362
+
363
+ if organic:
364
+ note("Organic content indicated -> Pt")
365
+ return "Pt", "Peat / Organic soil β€” compressible, poor engineering properties.", "Organic branch: Pt"
366
+
367
+ P2 = _readf(inputs, "P200", "P2")
368
+ note(f"P200 = {P2}%")
369
+
370
+ if P2 <= 50:
371
+ # Coarse-grained soils
372
+ P4 = _readf(inputs, "P4", "P4_sieve", "P40")
373
+ note(f"% passing #4 (P4) = {P4}%")
374
+ op = inputs.get("d_values_provided", None)
375
+ D60 = _readf(inputs, "D60")
376
+ D30 = _readf(inputs, "D30")
377
+ D10 = _readf(inputs, "D10")
378
+ if D60 != 0 and D30 != 0 and D10 != 0:
379
+ Cu = (D60 / D10) if D10 != 0 else 0
380
+ Cc = ((D30 ** 2) / (D10 * D60)) if (D10 * D60) != 0 else 0
381
+ note(f"D-values present -> D60={D60}, D30={D30}, D10={D10}, Cu={Cu}, Cc={Cc}")
382
+ else:
383
+ Cu = 0
384
+ Cc = 0
385
+ note("D-values missing or incomplete -> using Atterberg/fines-based branches")
386
+
387
+ LL = _readf(inputs, "LL")
388
+ PL = _readf(inputs, "PL")
389
+ PI = LL - PL
390
+ note(f"LL={LL}, PL={PL}, PI={PI}")
391
+
392
+ # Gravels
393
+ if P4 <= 50:
394
+ note("P4 <= 50 -> Gravel family")
395
+ if (Cu != 0) and (Cc != 0):
396
+ if (Cu >= 4) and (1 <= Cc <= 3):
397
+ note("Cu >=4 and 1<=Cc<=3 -> GW")
398
+ return "GW", "Well-graded gravel with excellent load-bearing capacity.", "GW via Cu/Cc"
399
+ elif not ((Cu < 4) and (1 <= Cc <= 3)):
400
+ note("Cu <4 or Cc out of 1..3 -> GP")
401
+ return "GP", "Poorly-graded gravel.", "GP via Cu/Cc"
402
+ else:
403
+ # no D-values: use fines/PI checks
404
+ if (PI < 4) or (PI < 0.73 * (LL - 20)):
405
+ note("PI < 4 or PI < 0.73*(LL-20) -> GM")
406
+ return "GM", "Silty gravel with moderate properties.", "GM via fines"
407
+ elif (PI > 7) and (PI > 0.73 * (LL - 20)):
408
+ note("PI > 7 and PI > 0.73*(LL-20) -> GC")
409
+ return "GC", "Clayey gravel β€” reduced drainage.", "GC via fines"
410
+ else:
411
+ note("Intermediate fines -> GM-GC")
412
+ return "GM-GC", "Mixed silt/clay in gravel β€” variable.", "GM-GC via fines"
413
+ else:
414
+ # Sands path
415
+ note("P4 > 50 -> Sand family")
416
+ if (Cu != 0) and (Cc != 0):
417
+ if (Cu >= 6) and (1 <= Cc <= 3):
418
+ note("Cu >= 6 and 1 <= Cc <= 3 -> SW")
419
+ return "SW", "Well-graded sand with good engineering behavior.", "SW via Cu/Cc"
420
+ elif not ((Cu < 6) and (1 <= Cc <= 3)):
421
+ note("Cu <6 or Cc out of 1..3 -> SP")
422
+ return "SP", "Poorly-graded sand.", "SP via Cu/Cc"
423
+ else:
424
+ if (PI < 4) or (PI <= 0.73 * (LL - 20)):
425
+ note("PI < 4 or PI <= 0.73*(LL-20) -> SM")
426
+ return "SM", "Silty sand β€” moderate engineering quality.", "SM via fines"
427
+ elif (PI > 7) and (PI > 0.73 * (LL - 20)):
428
+ note("PI > 7 and PI > 0.73*(LL-20) -> SC")
429
+ return "SC", "Clayey sand β€” reduced permeability and strength.", "SC via fines"
430
+ else:
431
+ note("Intermediate -> SM-SC")
432
+ return "SM-SC", "Sand mixed with fines (silt/clay).", "SM-SC via fines"
433
+ else:
434
+ # Fine-grained soils
435
+ note("P200 > 50 -> Fine-grained path")
436
+ LL = _readf(inputs, "LL")
437
+ PL = _readf(inputs, "PL")
438
+ PI = LL - PL
439
+ note(f"LL={LL}, PL={PL}, PI={PI}")
440
+
441
+ # Read behaviour options
442
+ nDS = int(_readf(inputs, "nDS", default=0))
443
+ nDIL = int(_readf(inputs, "nDIL", default=0))
444
+ nTG = int(_readf(inputs, "nTG", default=0))
445
+ note(f"Behavior options (nDS,nDIL,nTG) = ({nDS},{nDIL},{nTG})")
446
+
447
+ # Low plasticity fines
448
+ if LL < 50:
449
+ note("LL < 50 -> low plasticity branch")
450
+ if (20 <= LL < 50) and (PI <= 0.73 * (LL - 20)):
451
+ note("20 <= LL < 50 and PI <= 0.73*(LL-20)")
452
+ if (nDS == 1) or (nDIL == 3) or (nTG == 3):
453
+ note("-> ML")
454
+ return "ML", "Silt of low plasticity.", "ML via LL/PI/observations"
455
+ elif (nDS == 3) or (nDIL == 3) or (nTG == 3):
456
+ note("-> OL (organic silt)")
457
+ return "OL", "Organic silt β€” compressible.", "OL via observations"
458
+ else:
459
+ note("-> ML-OL (ambiguous)")
460
+ return "ML-OL", "Mixed silt/organic.", "ML-OL via ambiguity"
461
+ elif (10 <= LL <= 30) and (4 <= PI <= 7) and (PI > 0.72 * (LL - 20)):
462
+ note("10 <= LL <=30 and 4<=PI<=7 and PI > 0.72*(LL-20)")
463
+ if (nDS == 1) or (nDIL == 1) or (nTG == 1):
464
+ note("-> ML")
465
+ return "ML", "Low plasticity silt", "ML via specific conditions"
466
+ elif (nDS == 2) or (nDIL == 2) or (nTG == 2):
467
+ note("-> CL")
468
+ return "CL", "Low plasticity clay", "CL via specific conditions"
469
+ else:
470
+ note("-> ML-CL (ambiguous)")
471
+ return "ML-CL", "Mixed ML/CL", "ML-CL via ambiguity"
472
+ else:
473
+ note("Default low-plasticity branch -> CL")
474
+ return "CL", "Low plasticity clay", "CL default"
475
+ else:
476
+ # High plasticity fines
477
+ note("LL >= 50 -> high plasticity branch")
478
+ if PI < 0.73 * (LL - 20):
479
+ note("PI < 0.73*(LL-20)")
480
+ if (nDS == 3) or (nDIL == 4) or (nTG == 4):
481
+ note("-> MH")
482
+ return "MH", "Elastic silt (high LL)", "MH via observations"
483
+ elif (nDS == 2) or (nDIL == 2) or (nTG == 4):
484
+ note("-> OH")
485
+ return "OH", "Organic high plasticity silt/clay", "OH via observations"
486
+ else:
487
+ note("-> MH-OH (ambiguous)")
488
+ return "MH-OH", "Mixed MH/OH", "MH-OH via ambiguity"
489
+ else:
490
+ note("PI >= 0.73*(LL-20) -> CH")
491
+ return "CH", "High plasticity clay β€” compressible, problematic for foundations.", "CH default high-PL"
492
+
493
+ note("Fell through branches -> UNCLASSIFIED")
494
+ return "UNCLASSIFIED", "Insufficient data for USCS classification.", "No valid decision path"
495
+
496
+ # ----------------------------
497
+ # Engineering descriptors & LaTeX-table mapping
498
+ # ----------------------------
499
+ ENGINEERING_TABLE = {
500
+ "Gravel": {
501
+ "Settlement": "None",
502
+ "Quicksand": "Impossible",
503
+ "Frost": "None",
504
+ "Groundwater lowering": "Possible",
505
+ "Cement grouting": "Possible",
506
+ "Silicate/bitumen": "Unsuitable",
507
+ "Compressed air": "Possible (loss of air, slow progress)"
508
+ },
509
+ "Coarse sand": {
510
+ "Settlement": "None",
511
+ "Quicksand": "Impossible",
512
+ "Frost": "None",
513
+ "Groundwater lowering": "Suitable",
514
+ "Cement grouting": "Possible only if very coarse",
515
+ "Silicate/bitumen": "Suitable",
516
+ "Compressed air": "Suitable"
517
+ },
518
+ "Medium sand": {
519
+ "Settlement": "None",
520
+ "Quicksand": "Unlikely",
521
+ "Frost": "None",
522
+ "Groundwater lowering": "Suitable",
523
+ "Cement grouting": "Impossible",
524
+ "Silicate/bitumen": "Suitable",
525
+ "Compressed air": "Suitable"
526
+ },
527
+ "Fine sand": {
528
+ "Settlement": "None",
529
+ "Quicksand": "Liable",
530
+ "Frost": "None",
531
+ "Groundwater lowering": "Suitable",
532
+ "Cement grouting": "Impossible",
533
+ "Silicate/bitumen": "Not possible in very fine sands",
534
+ "Compressed air": "Suitable"
535
+ },
536
+ "Silt": {
537
+ "Settlement": "Occurs",
538
+ "Quicksand": "Liable (coarse silts / silty sands)",
539
+ "Frost": "Occurs",
540
+ "Groundwater lowering": "Impossible (except electro-osmosis)",
541
+ "Cement grouting": "Impossible",
542
+ "Silicate/bitumen": "Impossible",
543
+ "Compressed air": "Suitable"
544
+ },
545
+ "Clay": {
546
+ "Settlement": "Occurs",
547
+ "Quicksand": "Impossible",
548
+ "Frost": "None",
549
+ "Groundwater lowering": "Impossible",
550
+ "Cement grouting": "Only in stiff, fissured clay",
551
+ "Silicate/bitumen": "Impossible",
552
+ "Compressed air": "Used for support only (Glossop & Skempton)"
553
+ }
554
+ }
555
+
556
+ def engineering_characteristics_from_uscs(uscs_code: str) -> Dict[str,str]:
557
+ # map family codes to table entries
558
+ if uscs_code.startswith("G"):
559
+ return ENGINEERING_TABLE["Gravel"]
560
+ if uscs_code.startswith("S"):
561
+ # differentiate coarse/medium/fine sand? We'll return Medium sand baseline
562
+ return ENGINEERING_TABLE["Medium sand"]
563
+ if uscs_code in ("ML","MH","OL","OH"):
564
+ return ENGINEERING_TABLE["Silt"]
565
+ if uscs_code.startswith("C") or uscs_code == "CL" or uscs_code == "CH":
566
+ return ENGINEERING_TABLE["Clay"]
567
+ # default
568
+ return {"Settlement":"Varies", "Quicksand":"Varies", "Frost":"Varies"}
569
+
570
+ # ----------------------------
571
+ # Combined classifier that produces a rich result
572
+ # ----------------------------
573
+ def classify_all(inputs: Dict[str,Any]) -> Dict[str,Any]:
574
+ """
575
+ Run both AASHTO & USCS verbatim logic and return a dictionary with:
576
+ - AASHTO_code, AASHTO_desc, GI, AASHTO_decision_path
577
+ - USCS_code, USCS_desc, USCS_decision_path
578
+ - engineering_characteristics (dict)
579
+ - engineering_summary (short deterministic summary)
580
+ """
581
+ aashto_code, aashto_desc, GI, aashto_path = classify_aashto_verbatim(inputs)
582
+ uscs_code, uscs_desc, uscs_path = classify_uscs_verbatim(inputs)
583
+
584
+ eng_chars = engineering_characteristics_from_uscs(uscs_code)
585
+
586
+ # Deterministic engineering summary
587
+ summary_lines = []
588
+ summary_lines.append(f"USCS: {uscs_code} β€” {uscs_desc}")
589
+ summary_lines.append(f"AASHTO: {aashto_code} β€” {aashto_desc}")
590
+ summary_lines.append(f"Group Index: {GI}")
591
+ # family derived remarks
592
+ if uscs_code.startswith("C") or uscs_code in ("CH","CL"):
593
+ summary_lines.append("Clayey behavior: expect significant compressibility, low permeability, potential long-term settlement β€” advisable to assess consolidation & use deep foundations for heavy loads.")
594
+ elif uscs_code.startswith("G") or uscs_code.startswith("S"):
595
+ summary_lines.append("Granular behavior: good drainage and bearing; suitable for shallow foundations/pavements when properly compacted.")
596
+ elif uscs_code in ("ML","MH","OL","OH"):
597
+ summary_lines.append("Silty/organic behavior: moderate-to-high compressibility; frost-susceptible; avoid as direct support for heavy structures without treatment.")
598
+ else:
599
+ summary_lines.append("Mixed or unclear behavior; recommend targeted lab testing and conservative design assumptions.")
600
+
601
+ out = {
602
+ "AASHTO_code": aashto_code,
603
+ "AASHTO_description": aashto_desc,
604
+ "GI": GI,
605
+ "AASHTO_decision_path": aashto_path,
606
+ "USCS_code": uscs_code,
607
+ "USCS_description": uscs_desc,
608
+ "USCS_decision_path": uscs_path,
609
+ "engineering_characteristics": eng_chars,
610
+ "engineering_summary": "\n".join(summary_lines)
611
+ }
612
+ return out
613
+
614
+ # ----------------------------
615
+ # LLM integration (Groq) to produce a rich humanized report
616
+ # ----------------------------
617
+ def call_groq_for_explanation(prompt: str, model_name: str = "meta-llama/llama-4-maverick-17b-128e-instruct", max_tokens: int = 800) -> str:
618
+ """
619
+ Use Groq client via REST if GROQ_API_KEY in st.secrets
620
+ (Note: adapt to your Groq client wrapper if you have it)
621
+ """
622
+ key = None
623
+ # check st.secrets first
624
+ if "GROQ_API_KEY" in st.secrets:
625
+ key = st.secrets["GROQ_API_KEY"]
626
+ else:
627
+ key = st.session_state.get("GROQ_API_KEY") or None
628
+
629
+ if not key:
630
+ return "Groq API key not found. LLM humanized explanation not available."
631
+
632
+ url = "https://api.groq.com/v1/chat/completions"
633
+ headers = {"Authorization": f"Bearer {key}", "Content-Type":"application/json"}
634
+ payload = {
635
+ "model": model_name,
636
+ "messages": [
637
+ {"role":"system","content":"You are GeoMate, a professional geotechnical engineering assistant."},
638
+ {"role":"user","content": prompt}
639
+ ],
640
+ "temperature": 0.2,
641
+ "max_tokens": max_tokens
642
+ }
643
+ try:
644
+ resp = requests.post(url, headers=headers, json=payload, timeout=60)
645
+ resp.raise_for_status()
646
+ data = resp.json()
647
+ # try to extract content defensively
648
+ if "choices" in data and len(data["choices"])>0:
649
+ content = data["choices"][0].get("message", {}).get("content") or data["choices"][0].get("text") or str(data["choices"][0])
650
+ return content
651
+ return json.dumps(data)
652
+ except Exception as e:
653
+ return f"LLM call failed: {e}"
654
+
655
+ # ----------------------------
656
+ # Build PDF bytes for classification report
657
+ # ----------------------------
658
+ def build_classification_pdf_bytes(site: Dict[str,Any], classification: Dict[str,Any], explanation_text: str) -> bytes:
659
+ buf = io.BytesIO()
660
+ doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=18*mm, rightMargin=18*mm, topMargin=18*mm, bottomMargin=18*mm)
661
+ styles = getSampleStyleSheet()
662
+ title_style = ParagraphStyle("title", parent=styles["Title"], fontSize=18, textColor=colors.HexColor("#FF6600"), alignment=1)
663
+ h1 = ParagraphStyle("h1", parent=styles["Heading1"], fontSize=12, textColor=colors.HexColor("#FF6600"))
664
+ body = ParagraphStyle("body", parent=styles["BodyText"], fontSize=10)
665
+
666
+ elems = []
667
+ elems.append(Paragraph("GeoMate V2 β€” Classification Report", title_style))
668
+ elems.append(Spacer(1,6))
669
+ elems.append(Paragraph(f"Site: {site.get('Site Name','Unnamed')}", h1))
670
+ elems.append(Paragraph(f"Date: {st.datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}", body))
671
+ elems.append(Spacer(1,8))
672
+
673
+ # Inputs summary
674
+ elems.append(Paragraph("Laboratory Inputs", h1))
675
+ inputs = site.get("classifier_inputs", {})
676
+ if inputs:
677
+ data = [["Parameter","Value"]]
678
+ for k,v in inputs.items():
679
+ data.append([str(k), str(v)])
680
+ table = Table(data, colWidths=[80*mm, 80*mm])
681
+ 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)]))
682
+ elems.append(table)
683
+ else:
684
+ elems.append(Paragraph("No lab inputs recorded.", body))
685
+ elems.append(Spacer(1,8))
686
+
687
+ # Deterministic results
688
+ elems.append(Paragraph("Deterministic Classification Results", h1))
689
+ elems.append(Paragraph(f"USCS: {classification.get('USCS_code','N/A')} β€” {classification.get('USCS_description','')}", body))
690
+ elems.append(Paragraph(f"AASHTO: {classification.get('AASHTO_code','N/A')} β€” {classification.get('AASHTO_description','')}", body))
691
+ elems.append(Paragraph(f"Group Index: {classification.get('GI','N/A')}", body))
692
+ elems.append(Spacer(1,6))
693
+ elems.append(Paragraph("USCS decision path (verbatim):", h1))
694
+ elems.append(Paragraph(classification.get("USCS_decision_path","Not recorded"), body))
695
+ elems.append(Spacer(1,6))
696
+ elems.append(Paragraph("AASHTO decision path (verbatim):", h1))
697
+ elems.append(Paragraph(classification.get("AASHTO_decision_path","Not recorded"), body))
698
+ elems.append(Spacer(1,8))
699
+
700
+ # Engineering characteristics table
701
+ elems.append(Paragraph("Engineering Characteristics (from reference table)", h1))
702
+ eng = classification.get("engineering_characteristics", {})
703
+ if eng:
704
+ eng_data = [["Property","Value"]]
705
+ for k,v in eng.items():
706
+ eng_data.append([k, v])
707
+ t2 = Table(eng_data, colWidths=[60*mm, 100*mm])
708
+ 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)]))
709
+ elems.append(t2)
710
+ elems.append(Spacer(1,8))
711
+
712
+ # LLM Explanation (humanized)
713
+ elems.append(Paragraph("Humanized Engineering Explanation (LLM)", h1))
714
+ if explanation_text:
715
+ # avoid overly long text blocks; split into paragraphs
716
+ for para in explanation_text.strip().split("\n\n"):
717
+ elems.append(Paragraph(para.strip().replace("\n"," "), body))
718
+ elems.append(Spacer(1,4))
719
+ else:
720
+ elems.append(Paragraph("No LLM explanation available.", body))
721
+
722
+ # Map snapshot (optional)
723
+ if "map_snapshot" in site and site["map_snapshot"]:
724
+ snap = site["map_snapshot"]
725
+ # If snapshot is HTML, skip embedding; if it's an image path, include it.
726
+ if isinstance(snap, str) and snap.lower().endswith((".png",".jpg",".jpeg")) and os.path.exists(snap):
727
+ elems.append(PageBreak())
728
+ elems.append(Paragraph("Map Snapshot", h1))
729
+ elems.append(RLImage(snap, width=160*mm, height=90*mm))
730
+
731
+ doc.build(elems)
732
+ pdf_bytes = buf.getvalue()
733
+ buf.close()
734
+ return pdf_bytes
735
+
736
+ # ----------------------------
737
+ # Streamlit Chat-style Soil Classifier Page
738
+ # ----------------------------
739
+ def soil_classifier_page():
740
+ st.header("🧭 Soil Classifier β€” USCS & AASHTO (Verbatim)")
741
+
742
+ site = get_active_site()
743
+ if site is None:
744
+ st.warning("No active site. Add a site first in the sidebar.")
745
+ return
746
+
747
+ # Ensure classifier_inputs exists
748
+ site.setdefault("classifier_inputs", {})
749
+
750
+ col1, col2 = st.columns([2,1])
751
+ with col1:
752
+ st.markdown("**Upload lab sheet (image) for OCR** β€” the extracted values will auto-fill classifier inputs.")
753
+ uploaded = st.file_uploader("Upload image (png/jpg)", type=["png","jpg","jpeg"], key="clf_ocr_upload")
754
+ if uploaded:
755
+ img = Image.open(uploaded)
756
+ st.image(img, caption="Uploaded lab sheet (OCR)", use_column_width=True)
757
+ try:
758
+ raw_text = pytesseract.image_to_string(img)
759
+ st.text_area("OCR raw text (preview)", raw_text, height=180)
760
+ # Basic numeric extraction heuristics (LL, PL, P200, P4, D60/D30/D10)
761
+ # Try many patterns for robustness
762
+ def find_first(pattern):
763
+ m = re.search(pattern, raw_text, re.IGNORECASE)
764
+ return float(m.group(1)) if m else None
765
+
766
+ possible = {}
767
+ for pat_key, pats in {
768
+ "LL": [r"LL\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)", r"Liquid\s*Limit\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
769
+ "PL": [r"PL\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)", r"Plastic\s*Limit\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
770
+ "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]+)?)"],
771
+ "P4":[r"%\s*Passing\s*#?4\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)", r"P4\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
772
+ "D60":[r"D60\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)", r"D_{60}\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
773
+ "D30":[r"D30\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"],
774
+ "D10":[r"D10\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"]
775
+ }.items():
776
+ for p in pats:
777
+ v = find_first(p)
778
+ if v is not None:
779
+ possible[pat_key] = v
780
+ break
781
+ # copy found to site inputs
782
+ for k,v in possible.items():
783
+ site["classifier_inputs"][k] = v
784
+ save_active_site(site)
785
+ st.success(f"OCR auto-filled: {', '.join([f'{k}={v}' for k,v in possible.items()])}")
786
+ except Exception as e:
787
+ st.error(f"OCR parsing failed: {e}")
788
+
789
+ 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.")
790
+ user_text = st.text_area("Enter parameters or notes", value="", key="clf_text_input", height=120)
791
+
792
+ if st.button("Run Classification"):
793
+ # parse user_text for numbers too (merge with site inputs)
794
+ txt = user_text or ""
795
+ # find key=value pairs
796
+ kvs = dict(re.findall(r"([A-Za-z0-9_%]+)\s*[=:\-]\s*([0-9]+(?:\.[0-9]+)?)", txt))
797
+ # normalize keys
798
+ norm = {}
799
+ for k,v in kvs.items():
800
+ klow = k.strip().lower()
801
+ if klow in ("ll","liquidlimit","liquid_limit","liquid"):
802
+ norm["LL"] = float(v)
803
+ elif klow in ("pl","plasticlimit","plastic_limit","plastic"):
804
+ norm["PL"] = float(v)
805
+ elif klow in ("pi","plasticityindex"):
806
+ norm["PI"] = float(v)
807
+ elif klow in ("p200","%200","p_200","passing200"):
808
+ norm["P200"] = float(v)
809
+ elif klow in ("p4","p_4","passing4"):
810
+ norm["P4"] = float(v)
811
+ elif klow in ("d60","d_60"):
812
+ norm["D60"] = float(v)
813
+ elif klow in ("d30","d_30"):
814
+ norm["D30"] = float(v)
815
+ elif klow in ("d10","d_10"):
816
+ norm["D10"] = float(v)
817
+ # merge into site inputs
818
+ site["classifier_inputs"].update(norm)
819
+ save_active_site(site)
820
+
821
+ # run verbatim classifiers
822
+ inputs_for_class = site["classifier_inputs"]
823
+ # ensure keys exist (coerce to numeric defaults)
824
+ result = classify_all(inputs_for_class)
825
+ # store result into site memory
826
+ site["classification_report"] = result
827
+ save_active_site(site)
828
+
829
+ st.success("Deterministic classification complete.")
830
+ st.markdown("**USCS result:** " + str(result.get("USCS_code")))
831
+ st.markdown("**AASHTO result:** " + str(result.get("AASHTO_code")) + f" (GI={result.get('GI')})")
832
+ st.markdown("**Engineering summary (deterministic):**")
833
+ st.info(result.get("engineering_summary"))
834
+
835
+ # call LLM to produce a humanized expanded report (if GROQ key exists)
836
+ prompt = f"""
837
+ You are GeoMate, a professional geotechnical engineer assistant.
838
+ Given the following laboratory inputs and deterministic classification, produce a clear, technical
839
+ and human-friendly classification report, explaining what the soil is, how it behaves, engineering
840
+ implications (bearing, settlement, stiffness), suitability for shallow foundations and road subgrades,
841
+ and practical recommendations for site engineering.
842
+
843
+ Site: {site.get('Site Name','Unnamed')}
844
+ Inputs (as parsed): {json.dumps(site.get('classifier_inputs',{}), indent=2)}
845
+ Deterministic classification results:
846
+ USCS: {result.get('USCS_code')}
847
+ USCS decision path: {result.get('USCS_decision_path')}
848
+ AASHTO: {result.get('AASHTO_code')}
849
+ AASHTO decision path: {result.get('AASHTO_decision_path')}
850
+ Group Index: {result.get('GI')}
851
+ Engineering characteristics reference table: {json.dumps(result.get('engineering_characteristics',{}), indent=2)}
852
+
853
+ Provide:
854
+ - Executive summary (3-5 sentences)
855
+ - Engineering interpretation (detailed)
856
+ - Specific recommendations (foundations, drainage, compaction, stabilization)
857
+ - Short checklist of items for further testing.
858
+ """
859
+ st.info("Generating humanized report via LLM (Groq) β€” this may take a few seconds.")
860
+ explanation = call_groq_for_explanation(prompt)
861
+ # fallback if failed
862
+ if explanation.startswith("LLM call failed") or explanation.startswith("Groq API key not found"):
863
+ # build local humanized explanation deterministically
864
+ explanation = ("Humanized explanation not available via LLM. "
865
+ "Deterministic summary: \n\n" + result.get("engineering_summary", "No summary."))
866
+
867
+ # save explanation to site memory
868
+ site.setdefault("reports", {})
869
+ site["reports"]["last_classification_explanation"] = explanation
870
+ save_active_site(site)
871
+
872
+ st.markdown("**Humanized Explanation (LLM or fallback):**")
873
+ st.write(explanation)
874
+
875
+ # Build PDF bytes and offer download
876
+ pdf_bytes = build_classification_pdf_bytes(site, result, explanation)
877
+ st.download_button("Download Classification PDF", data=pdf_bytes, file_name=f"classification_{site.get('Site Name','site')}.pdf", mime="application/pdf")
878
+
879
+ # side column shows current parsed inputs / last results
880
+ with col2:
881
+ st.markdown("**Current parsed inputs**")
882
+ st.json(site.get("classifier_inputs", {}))
883
+ st.markdown("**Last deterministic classification (if any)**")
884
+ st.json(site.get("classification_report", {}))
885
+
886
+ # End of snippet
887
+
888
  pass
889
 
890