Update app.py
Browse files
app.py
CHANGED
|
@@ -515,4 +515,410 @@ def soil_classifier_ui():
|
|
| 515 |
sel = st.selectbox("", DILATANCY_OPTIONS, index=0)
|
| 516 |
if st.button("Submit dilatancy"):
|
| 517 |
ans = sel
|
| 518 |
-
elif key == "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
sel = st.selectbox("", DILATANCY_OPTIONS, index=0)
|
| 516 |
if st.button("Submit dilatancy"):
|
| 517 |
ans = sel
|
| 518 |
+
elif key == "toughness":
|
| 519 |
+
sel = st.selectbox("", TOUGHNESS_OPTIONS, index=0)
|
| 520 |
+
if st.button("Submit toughness"):
|
| 521 |
+
ans = sel
|
| 522 |
+
elif key == "confirm":
|
| 523 |
+
if st.button("Classify now"):
|
| 524 |
+
ans = "go"
|
| 525 |
+
|
| 526 |
+
# process answer if provided
|
| 527 |
+
if ans is not None:
|
| 528 |
+
# save chat turn
|
| 529 |
+
site.setdefault("classifier_chat", []).append(("bot", qtext))
|
| 530 |
+
site["classifier_chat"].append(("user", str(ans)))
|
| 531 |
+
# map answers
|
| 532 |
+
if key == "organic_check":
|
| 533 |
+
site["classifier_inputs"]["opt"] = "y" if ans == "y" else "n"
|
| 534 |
+
site["classifier_state"] += 1
|
| 535 |
+
elif key in ("p2","p4","d60","d30","d10","ll","pl"):
|
| 536 |
+
# number input: get current value from widget
|
| 537 |
+
val = float(st.session_state.get(f"num_{key}", ans))
|
| 538 |
+
site["classifier_inputs"][key.upper() if key in ("p2","p4") else key.upper()] = val
|
| 539 |
+
# normalize keys consistent with uscs function: P2, P4, D60, D30, D10, LL, PL
|
| 540 |
+
# store lower-case keys also
|
| 541 |
+
if key == "p2":
|
| 542 |
+
site["classifier_inputs"]["P2"] = val
|
| 543 |
+
elif key == "p4":
|
| 544 |
+
site["classifier_inputs"]["P4"] = val
|
| 545 |
+
elif key == "d60":
|
| 546 |
+
site["classifier_inputs"]["D60"] = val
|
| 547 |
+
elif key == "d30":
|
| 548 |
+
site["classifier_inputs"]["D30"] = val
|
| 549 |
+
elif key == "d10":
|
| 550 |
+
site["classifier_inputs"]["D10"] = val
|
| 551 |
+
elif key == "ll":
|
| 552 |
+
site["classifier_inputs"]["LL"] = val
|
| 553 |
+
elif key == "pl":
|
| 554 |
+
site["classifier_inputs"]["PL"] = val
|
| 555 |
+
site["classifier_state"] += 1
|
| 556 |
+
elif key == "d_values_known":
|
| 557 |
+
if ans == "yes":
|
| 558 |
+
site["classifier_state"] += 1 # will ask D60 next
|
| 559 |
+
else:
|
| 560 |
+
# redirect user to GSD curve page to upload/plot
|
| 561 |
+
st.success("Please go to GSD Curve page to upload or paste your sieve analysis. After saving D10/D30/D60 there, return here and continue.")
|
| 562 |
+
site["classifier_state"] += 3 # skip D60/D30/D10 questions for now
|
| 563 |
+
elif key == "dry_strength":
|
| 564 |
+
site["classifier_inputs"]["nDS"] = DRY_STRENGTH_MAP.get(ans, 5)
|
| 565 |
+
site["classifier_state"] += 1
|
| 566 |
+
elif key == "dilatancy":
|
| 567 |
+
site["classifier_inputs"]["nDIL"] = DILATANCY_MAP.get(ans, 6)
|
| 568 |
+
site["classifier_state"] += 1
|
| 569 |
+
elif key == "toughness":
|
| 570 |
+
site["classifier_inputs"]["nTG"] = TOUGHNESS_MAP.get(ans, 6)
|
| 571 |
+
site["classifier_state"] += 1
|
| 572 |
+
elif key == "confirm":
|
| 573 |
+
# classification step
|
| 574 |
+
# adapt keys to uscs function names
|
| 575 |
+
inps = {
|
| 576 |
+
"opt": site["classifier_inputs"].get("opt","n"),
|
| 577 |
+
"P2": site["classifier_inputs"].get("P2",0.0),
|
| 578 |
+
"P4": site["classifier_inputs"].get("P4",0.0),
|
| 579 |
+
"D60": site["classifier_inputs"].get("D60",0.0),
|
| 580 |
+
"D30": site["classifier_inputs"].get("D30",0.0),
|
| 581 |
+
"D10": site["classifier_inputs"].get("D10",0.0),
|
| 582 |
+
"LL": site["classifier_inputs"].get("LL",0.0),
|
| 583 |
+
"PL": site["classifier_inputs"].get("PL",0.0),
|
| 584 |
+
"nDS": site["classifier_inputs"].get("nDS",5),
|
| 585 |
+
"nDIL": site["classifier_inputs"].get("nDIL",6),
|
| 586 |
+
"nTG": site["classifier_inputs"].get("nTG",6)
|
| 587 |
+
}
|
| 588 |
+
try:
|
| 589 |
+
res_text, uscs, aashto, GI, expl, chars = uscs_aashto_logic(inps)
|
| 590 |
+
site["USCS"] = uscs
|
| 591 |
+
site["AASHTO"] = aashto
|
| 592 |
+
site["GI"] = GI
|
| 593 |
+
site["classifier_decision_path"] = res_text
|
| 594 |
+
site["classifier_inputs"].update(inps)
|
| 595 |
+
# Save classification to shared dict for quick access
|
| 596 |
+
ss.soil_classifications[site.get("Site Name","Site")] = {
|
| 597 |
+
"USCS": uscs, "AASHTO": aashto, "GI": GI, "inputs": inps, "decision_path": res_text
|
| 598 |
+
}
|
| 599 |
+
st.success("Classification complete. Results saved to site.")
|
| 600 |
+
st.markdown("**Results**")
|
| 601 |
+
st.markdown(res_text)
|
| 602 |
+
except Exception as e:
|
| 603 |
+
st.error(f"Classification failed: {e}")
|
| 604 |
+
site["classifier_state"] = len(questions) # finish
|
| 605 |
+
save_active_site(site)
|
| 606 |
+
else:
|
| 607 |
+
st.markdown("🟢 Classification flow completed. See saved results in the Results tab or site JSON.")
|
| 608 |
+
|
| 609 |
+
# -----------------------------
|
| 610 |
+
# --- GSD Curve UI
|
| 611 |
+
# -----------------------------
|
| 612 |
+
def gsd_curve_ui():
|
| 613 |
+
st.header("📊 Grain Size Distribution (GSD) Curve")
|
| 614 |
+
site = get_active_site()
|
| 615 |
+
if not site:
|
| 616 |
+
st.info("Create/select a site first.")
|
| 617 |
+
return
|
| 618 |
+
st.markdown("You can paste a comma-separated list of sieve diameters (mm) and corresponding % passing, or upload a CSV with two columns: diameter_mm, percent_passing.")
|
| 619 |
+
diameters_text = st.text_area("Diameters (mm) comma-separated (descending preferred)", value=site.get("GSD",{}).get("diameters_str","75,50,37.5,25,19,12.5,9.5,4.75,2,0.85,0.425,0.25,0.18,0.15,0.075"))
|
| 620 |
+
passing_text = st.text_area("% Passing comma-separated", value=site.get("GSD",{}).get("passing_str","100,98,96,90,85,78,72,65,55,45,35,25,18,14,8"))
|
| 621 |
+
uploaded = st.file_uploader("Or upload CSV (diameter_mm,percent_passing)", type=["csv"])
|
| 622 |
+
if uploaded:
|
| 623 |
+
try:
|
| 624 |
+
import pandas as pd
|
| 625 |
+
df = pd.read_csv(uploaded)
|
| 626 |
+
d = df.iloc[:,0].astype(float).tolist()
|
| 627 |
+
p = df.iloc[:,1].astype(float).tolist()
|
| 628 |
+
except Exception as e:
|
| 629 |
+
st.error(f"CSV parse failed: {e}")
|
| 630 |
+
return
|
| 631 |
+
else:
|
| 632 |
+
try:
|
| 633 |
+
d = [float(x.strip()) for x in diameters_text.split(",") if x.strip()]
|
| 634 |
+
p = [float(x.strip()) for x in passing_text.split(",") if x.strip()]
|
| 635 |
+
except Exception as e:
|
| 636 |
+
st.error(f"Parse error: {e}")
|
| 637 |
+
return
|
| 638 |
+
if len(d) != len(p):
|
| 639 |
+
st.error("Diameters and passing values must have same length.")
|
| 640 |
+
return
|
| 641 |
+
try:
|
| 642 |
+
# compute D10,D30,D60
|
| 643 |
+
D10 = interpolate_D_from_gsd(d,p,10.0)
|
| 644 |
+
D30 = interpolate_D_from_gsd(d,p,30.0)
|
| 645 |
+
D60 = interpolate_D_from_gsd(d,p,60.0)
|
| 646 |
+
Cu = D60 / D10 if D10>0 else 0
|
| 647 |
+
Cc = (D30**2) / (D10*D60) if (D10>0 and D60>0) else 0
|
| 648 |
+
|
| 649 |
+
# plot
|
| 650 |
+
fig, ax = plt.subplots(figsize=(6,4))
|
| 651 |
+
ax.semilogx(d, p, marker='o')
|
| 652 |
+
ax.set_xlabel("Particle size (mm) - log scale")
|
| 653 |
+
ax.set_ylabel("% Passing")
|
| 654 |
+
ax.set_title("Grain Size Distribution")
|
| 655 |
+
ax.grid(True, which='both', linestyle='--', linewidth=0.5)
|
| 656 |
+
# annotate D-values
|
| 657 |
+
ax.axhline(10, color='gray', linewidth=0.6, linestyle=':')
|
| 658 |
+
ax.axhline(30, color='gray', linewidth=0.6, linestyle=':')
|
| 659 |
+
ax.axhline(60, color='gray', linewidth=0.6, linestyle=':')
|
| 660 |
+
ax.annotate(f"D10={D10:.4g} mm", xy=(D10,10), xytext=(D10,15))
|
| 661 |
+
ax.annotate(f"D30={D30:.4g} mm", xy=(D30,30), xytext=(D30,40))
|
| 662 |
+
ax.annotate(f"D60={D60:.4g} mm", xy=(D60,60), xytext=(D60,70))
|
| 663 |
+
st.pyplot(fig)
|
| 664 |
+
|
| 665 |
+
if st.button("Save D-values to active site"):
|
| 666 |
+
site["GSD"] = {
|
| 667 |
+
"diameters": d, "passing": p,
|
| 668 |
+
"D10": D10, "D30": D30, "D60": D60,
|
| 669 |
+
"Cu": Cu, "Cc": Cc,
|
| 670 |
+
"diameters_str": diameters_text, "passing_str": passing_text
|
| 671 |
+
}
|
| 672 |
+
# also save D values into classifier inputs (if present)
|
| 673 |
+
site.setdefault("classifier_inputs", {})["D10"] = D10
|
| 674 |
+
site["classifier_inputs"]["D30"] = D30
|
| 675 |
+
site["classifier_inputs"]["D60"] = D60
|
| 676 |
+
save_active_site(site)
|
| 677 |
+
st.success("Saved D-values and GSD to site.")
|
| 678 |
+
st.markdown(f"D10 = {D10:.5g} mm, D30 = {D30:.5g} mm, D60 = {D60:.5g} mm, Cu = {Cu:.3g}, Cc = {Cc:.3g}")
|
| 679 |
+
except Exception as e:
|
| 680 |
+
st.error(f"GSD error: {e}")
|
| 681 |
+
|
| 682 |
+
# -----------------------------
|
| 683 |
+
# --- Locator UI (simple, EE optional)
|
| 684 |
+
# -----------------------------
|
| 685 |
+
def try_initialize_ee():
|
| 686 |
+
# attempt to initialize Earth Engine if secrets present
|
| 687 |
+
global EE_READY
|
| 688 |
+
if not EE_AVAILABLE:
|
| 689 |
+
return False
|
| 690 |
+
try:
|
| 691 |
+
import ee, geemap
|
| 692 |
+
key_json = st.secrets["EE_PRIVATE_KEY"]
|
| 693 |
+
service_account = st.secrets["SERVICE_ACCOUNT"]
|
| 694 |
+
# attempt init using ServiceAccountCredentials
|
| 695 |
+
try:
|
| 696 |
+
creds = ee.ServiceAccountCredentials(service_account, key_json)
|
| 697 |
+
ee.Initialize(creds)
|
| 698 |
+
EE_READY = True
|
| 699 |
+
return True
|
| 700 |
+
except Exception as e:
|
| 701 |
+
# try alternate (if key_json is a filepath)
|
| 702 |
+
try:
|
| 703 |
+
ee.Initialize()
|
| 704 |
+
EE_READY = True
|
| 705 |
+
return True
|
| 706 |
+
except Exception:
|
| 707 |
+
EE_READY = False
|
| 708 |
+
return False
|
| 709 |
+
except Exception:
|
| 710 |
+
EE_READY = False
|
| 711 |
+
return False
|
| 712 |
+
|
| 713 |
+
def locator_ui():
|
| 714 |
+
st.header("🌍 Locator (chatbot + map)")
|
| 715 |
+
site = get_active_site()
|
| 716 |
+
if not site:
|
| 717 |
+
st.info("Create/select a site first.")
|
| 718 |
+
return
|
| 719 |
+
# Initialize EE if possible (lazy)
|
| 720 |
+
if ss.secrets_status.get("ee_key_found", False) and not EE_READY:
|
| 721 |
+
try_initialize_ee()
|
| 722 |
+
|
| 723 |
+
st.markdown("GeoMate: Please provide the site's center coordinates or paste a GeoJSON polygon in the box below.")
|
| 724 |
+
cols = st.columns(2)
|
| 725 |
+
lat = cols[0].number_input("Latitude", value=site.get("lat") or 0.0, format="%.6f")
|
| 726 |
+
lon = cols[1].number_input("Longitude", value=site.get("lon") or 0.0, format="%.6f")
|
| 727 |
+
geojson = st.text_area("Optional: Paste GeoJSON polygon for boundary", value="")
|
| 728 |
+
if st.button("Save coordinates & fetch data"):
|
| 729 |
+
# save coords
|
| 730 |
+
site["lat"] = lat
|
| 731 |
+
site["lon"] = lon
|
| 732 |
+
site["Site Coordinates"] = f"{lat:.6f}, {lon:.6f}"
|
| 733 |
+
save_active_site(site)
|
| 734 |
+
st.success("Coordinates saved to site.")
|
| 735 |
+
# fetch EE data if available
|
| 736 |
+
if EE_READY:
|
| 737 |
+
st.info("Fetching Earth Engine data (this may take a moment)...")
|
| 738 |
+
try:
|
| 739 |
+
import ee, geemap
|
| 740 |
+
# placeholder: set site fields
|
| 741 |
+
site["Soil Profile"] = f"EE soil profile placeholder at {lat},{lon}"
|
| 742 |
+
site["Flood Data"] = f"EE flood summary placeholder (20-year history)"
|
| 743 |
+
site["Seismic Data"] = f"EE seismic summary placeholder"
|
| 744 |
+
site["Topography"] = f"Elevation approx. {int(100+lat)%400} m (EE placeholder)"
|
| 745 |
+
# attempt to get a quick map snapshot via geemap (if supported)
|
| 746 |
+
try:
|
| 747 |
+
m = geemap.Map(center=[lat, lon], zoom=10)
|
| 748 |
+
# add a marker (geemap uses ipyleaflet/folium depending); to avoid complex saving, we'll capture a PNG via to_image if available
|
| 749 |
+
try:
|
| 750 |
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
| 751 |
+
m.to_image(outfile=tmp.name)
|
| 752 |
+
site["map_snapshot"] = tmp.name
|
| 753 |
+
except Exception:
|
| 754 |
+
site["map_snapshot"] = None
|
| 755 |
+
except Exception:
|
| 756 |
+
site["map_snapshot"] = None
|
| 757 |
+
save_active_site(site)
|
| 758 |
+
st.success("Earth Engine data saved to site.")
|
| 759 |
+
except Exception as e:
|
| 760 |
+
st.error(f"Earth Engine fetch failed: {e}")
|
| 761 |
+
else:
|
| 762 |
+
# fallback: save simple placeholders and create a simple static map image
|
| 763 |
+
site["Soil Profile"] = f"Sample soil summary at {lat},{lon}"
|
| 764 |
+
site["Flood Data"] = "Flood history: not available (EE not configured)."
|
| 765 |
+
site["Seismic Data"] = "Seismic data: not available (EE not configured)."
|
| 766 |
+
# create a simple static map-like image with a marker using matplotlib
|
| 767 |
+
fig, ax = plt.subplots(figsize=(6,3))
|
| 768 |
+
ax.text(0.5, 0.6, f"Map placeholder\n{site['Site Name']}\n{lat:.6f}, {lon:.6f}", ha='center', va='center', fontsize=12)
|
| 769 |
+
ax.axis("off")
|
| 770 |
+
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
|
| 771 |
+
fig.savefig(tmp.name, bbox_inches='tight', dpi=150)
|
| 772 |
+
plt.close(fig)
|
| 773 |
+
site["map_snapshot"] = tmp.name
|
| 774 |
+
save_active_site(site)
|
| 775 |
+
st.info("Map snapshot saved (placeholder).")
|
| 776 |
+
|
| 777 |
+
# -----------------------------
|
| 778 |
+
# --- Reports UI & PDF builder
|
| 779 |
+
# -----------------------------
|
| 780 |
+
def build_full_report_pdf(site: Dict[str,Any]) -> bytes:
|
| 781 |
+
"""
|
| 782 |
+
Build a professional PDF using ReportLab, returns bytes.
|
| 783 |
+
"""
|
| 784 |
+
buf = io.BytesIO()
|
| 785 |
+
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=20*mm, rightMargin=20*mm, topMargin=20*mm, bottomMargin=15*mm)
|
| 786 |
+
styles = getSampleStyleSheet()
|
| 787 |
+
title_style = ParagraphStyle("title", parent=styles["Title"], fontSize=20, alignment=1, textColor=colors.HexColor("#FF7A00"))
|
| 788 |
+
h1 = ParagraphStyle("h1", parent=styles["Heading1"], fontSize=14, textColor=colors.HexColor("#1F4E79"))
|
| 789 |
+
body = ParagraphStyle("body", parent=styles["BodyText"], fontSize=10, leading=12)
|
| 790 |
+
|
| 791 |
+
elems = []
|
| 792 |
+
# cover
|
| 793 |
+
elems.append(Spacer(1,12))
|
| 794 |
+
elems.append(Paragraph("GEOTECHNICAL INVESTIGATION REPORT", title_style))
|
| 795 |
+
elems.append(Spacer(1,12))
|
| 796 |
+
info_data = [
|
| 797 |
+
["Project", site.get("Site Name","-")],
|
| 798 |
+
["Coordinates", site.get("Site Coordinates","-")],
|
| 799 |
+
["Date", datetime.today().strftime("%Y-%m-%d")],
|
| 800 |
+
]
|
| 801 |
+
t = Table(info_data, colWidths=[60*mm, 100*mm])
|
| 802 |
+
t.setStyle(TableStyle([("GRID",(0,0),(-1,-1),0.5,colors.grey)]))
|
| 803 |
+
elems.append(t)
|
| 804 |
+
elems.append(Spacer(1,12))
|
| 805 |
+
|
| 806 |
+
# Summary (dummy narrative assembled from site dict)
|
| 807 |
+
elems.append(Paragraph("SUMMARY", h1))
|
| 808 |
+
summary_lines = []
|
| 809 |
+
summary_lines.append(f"The site '{site.get('Site Name')}' was investigated using GeoMate V2 tools and available data.")
|
| 810 |
+
if site.get("USCS"):
|
| 811 |
+
summary_lines.append(f"Classification: USCS = {site.get('USCS')}, AASHTO = {site.get('AASHTO')} (GI = {site.get('GI')}).")
|
| 812 |
+
if site.get("Load Bearing Capacity"):
|
| 813 |
+
summary_lines.append(f"Reported bearing capacity: {site.get('Load Bearing Capacity')}.")
|
| 814 |
+
if site.get("Soil Profile"):
|
| 815 |
+
summary_lines.append(f"Soil profile summary: {site.get('Soil Profile')}")
|
| 816 |
+
for ln in summary_lines:
|
| 817 |
+
elems.append(Paragraph(ln, body))
|
| 818 |
+
elems.append(Spacer(1,8))
|
| 819 |
+
|
| 820 |
+
# Field & Lab table placeholders
|
| 821 |
+
elems.append(Paragraph("FIELD INVESTIGATION AND LABORATORY TESTING", h1))
|
| 822 |
+
elems.append(Paragraph("Field observations and laboratory test results are summarized below.", body))
|
| 823 |
+
lab_rows = []
|
| 824 |
+
# try to extract some lab-like info from site
|
| 825 |
+
if site.get("GSD"):
|
| 826 |
+
g = site["GSD"]
|
| 827 |
+
lab_rows.append(["GSD D10 (mm)", f"{g.get('D10', '-'):.5g}" if g.get('D10') else "-"])
|
| 828 |
+
lab_rows.append(["GSD D30 (mm)", f"{g.get('D30', '-'):.5g}" if g.get('D30') else "-"])
|
| 829 |
+
lab_rows.append(["GSD D60 (mm)", f"{g.get('D60', '-'):.5g}" if g.get('D60') else "-"])
|
| 830 |
+
# create table
|
| 831 |
+
if lab_rows:
|
| 832 |
+
t2 = Table([["Test","Value"]] + lab_rows, colWidths=[70*mm,80*mm])
|
| 833 |
+
t2.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)]))
|
| 834 |
+
elems.append(t2)
|
| 835 |
+
elems.append(Spacer(1,8))
|
| 836 |
+
|
| 837 |
+
# Earth Engine section
|
| 838 |
+
elems.append(Paragraph("EARTH ENGINE / SITE DATA", h1))
|
| 839 |
+
elems.append(Paragraph(f"Soil profile: {site.get('Soil Profile','Not available')}", body))
|
| 840 |
+
elems.append(Paragraph(f"Flood data: {site.get('Flood Data','Not available')}", body))
|
| 841 |
+
elems.append(Paragraph(f"Seismic data: {site.get('Seismic Data','Not available')}", body))
|
| 842 |
+
elems.append(Paragraph(f"Topography: {site.get('Topography','Not available')}", body))
|
| 843 |
+
elems.append(Spacer(1,8))
|
| 844 |
+
|
| 845 |
+
# Insert map snapshot if present
|
| 846 |
+
if site.get("map_snapshot"):
|
| 847 |
+
try:
|
| 848 |
+
elems.append(Paragraph("Site Map", h1))
|
| 849 |
+
from reportlab.platypus import Image as RLImage
|
| 850 |
+
img_path = site["map_snapshot"]
|
| 851 |
+
im = RLImage(img_path, width=160*mm, height=90*mm)
|
| 852 |
+
elems.append(im)
|
| 853 |
+
elems.append(Spacer(1,8))
|
| 854 |
+
except Exception:
|
| 855 |
+
pass
|
| 856 |
+
|
| 857 |
+
# Evaluation & Recommendations (dummy, to be replaced by LLM if available)
|
| 858 |
+
elems.append(Paragraph("EVALUATION OF GEOTECHNICAL PROPERTIES", h1))
|
| 859 |
+
eval_text = site.get("classifier_decision_path","Detailed classification not available.")
|
| 860 |
+
elems.append(Paragraph(eval_text, body))
|
| 861 |
+
elems.append(Paragraph("RECOMMENDATIONS", h1))
|
| 862 |
+
recs = [
|
| 863 |
+
"Remove unconsolidated fill beneath foundation footprints.",
|
| 864 |
+
"Provide positive drainage away from structures.",
|
| 865 |
+
"Consider shallow foundations where competent soil is available at shallow depth; otherwise consider piled solutions."
|
| 866 |
+
]
|
| 867 |
+
for r in recs:
|
| 868 |
+
elems.append(Paragraph(f"- {r}", body))
|
| 869 |
+
|
| 870 |
+
elems.append(PageBreak())
|
| 871 |
+
# appendices placeholder
|
| 872 |
+
elems.append(Paragraph("APPENDICES", h1))
|
| 873 |
+
elems.append(Paragraph("Borehole logs, laboratory tables and GSD curves are included in the appendices when available.", body))
|
| 874 |
+
|
| 875 |
+
doc.build(elems)
|
| 876 |
+
pdf_bytes = buf.getvalue()
|
| 877 |
+
buf.close()
|
| 878 |
+
return pdf_bytes
|
| 879 |
+
|
| 880 |
+
def reports_ui():
|
| 881 |
+
st.header("📑 Reports")
|
| 882 |
+
site = get_active_site()
|
| 883 |
+
if not site:
|
| 884 |
+
st.info("Create/select a site first.")
|
| 885 |
+
return
|
| 886 |
+
|
| 887 |
+
st.markdown("GeoMate will ask a few final questions (chat-style) before generating the full geotechnical report.")
|
| 888 |
+
# simple chat-driven data collection
|
| 889 |
+
questions = [
|
| 890 |
+
("Load Bearing Capacity","Please provide the design bearing capacity (e.g., '200 kPa' or '2000 psf')."),
|
| 891 |
+
("Skin Shear Strength","What is the skin shear strength (if known)?"),
|
| 892 |
+
("Relative Compaction","What relative % compaction should be used for imported fill? (e.g., 95%)"),
|
| 893 |
+
("Rate of Consolidation","Provide the approximate consolidation rate (if known)."),
|
| 894 |
+
("Nature of Construction","Describe nature of construction (Residential, Commercial, Road, Dam, etc.)")
|
| 895 |
+
]
|
| 896 |
+
state = site.get("report_convo_state",0)
|
| 897 |
+
# display chat bubbles
|
| 898 |
+
if "report_chat" not in site:
|
| 899 |
+
site["report_chat"] = []
|
| 900 |
+
for sender, msg in site["report_chat"]:
|
| 901 |
+
if sender == "bot":
|
| 902 |
+
st.markdown(f"<div style='background:#111;padding:8px;border-radius:8px;color:#FF8C00;'>🤖 {msg}</div>", unsafe_allow_html=True)
|
| 903 |
+
else:
|
| 904 |
+
st.markdown(f"<div style='background:#0b0b0b;padding:8px;border-radius:8px;color:#9fd3ff;text-align:right'>👤 {msg}</div>", unsafe_allow_html=True)
|
| 905 |
+
|
| 906 |
+
if state < len(questions):
|
| 907 |
+
key, qtext = questions[state]
|
| 908 |
+
st.markdown(f"**GeoMate:** {qtext}")
|
| 909 |
+
ans = st.text_input("", key=f"report_input_{state}")
|
| 910 |
+
if st.button("Submit answer", key=f"report_submit_{state}"):
|
| 911 |
+
# save if not skip/dk
|
| 912 |
+
if ans.strip().lower() not in ["", "don't know", "dk", "skip", "n/a"]:
|
| 913 |
+
site[key] = ans.strip()
|
| 914 |
+
site.setdefault("report_chat", []).append(("bot", qtext))
|
| 915 |
+
site["report_chat"].append(("user", ans.strip()))
|
| 916 |
+
site["report_convo_state"] = state + 1
|
| 917 |
+
save_active_site(site)
|
| 918 |
+
st.experimental_rerun()
|
| 919 |
+
else:
|
| 920 |
+
st.success("All questions complete for report generation.")
|
| 921 |
+
if st.button("Generate Full Geotechnical Report (PDF)"):
|
| 922 |
+
with st.spinner("Building PDF..."):
|
| 923 |
+
pdf_bytes = build_full_report_pdf(site)
|
| 924 |
+
st.download_button("Download Report PDF", data=pdf_bytes, file_name=f"GeoMate_FullReport_{site.get('Site Name','site')}.pdf", mime="application/pdf")
|