File size: 8,650 Bytes
fca8237
 
 
 
 
 
 
 
 
 
694b174
 
 
 
 
fca8237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
694b174
 
 
 
 
 
 
 
fca8237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa6ccff
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
"""DBFT Pavement Strain Predictor — Gradio app for Hugging Face Spaces.

Predicts the two critical pavement strains directly from an FWD deflection
basin + layer thicknesses, using the best model from the paper (combined
loss lambda_f = 1.0, extended surrogate), with a local SHAP explanation
for every prediction.
"""

from pathlib import Path

try:
    import spaces  # ZeroGPU: must be imported before torch
except ImportError:  # local run / CPU Space without the spaces package
    spaces = None

import gradio as gr
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import shap
import torch

from fwd_fusion_transformer import DBFT, basin_indices

HERE = Path(__file__).resolve().parent
ASSETS = HERE / "assets"
TAG = "combined_lam1.0_ext"
FEATURES = ["D0", "D200", "D300", "D450", "D600", "D900", "D1200", "D1500",
            "D1800", "h_AC", "h_Base", "h_Subbase"]
NSAMPLES = 200

# ---------------- model ----------------
sc = np.load(ASSETS / f"dbft_{TAG}_scalers.npz")
model = DBFT()
model.load_state_dict(torch.load(ASSETS / f"dbft_{TAG}.pt", map_location="cpu"))
model.eval()


def f(X):
    """X: (n, 12) raw [D0..D1800 um, h_AC..h_Subbase mm] -> (n, 2) strains ue."""
    X = np.asarray(X, np.float32)
    D, H = X[:, :9], X[:, 9:12]
    I = basin_indices(D).astype(np.float32)
    t = lambda a, k: torch.tensor((a - sc[f"{k}_mean"]) / sc[f"{k}_std"],
                                  dtype=torch.float32)
    with torch.no_grad():
        p = model(t(D, "D"), t(I, "I"), t(H, "H")).numpy()
    return p * sc["Y_std"] + sc["Y_mean"]


bg = np.load(ASSETS / "bg_cache.npz")["bg"]
explainer = shap.KernelExplainer(f, bg)
BASE = np.asarray(explainer.expected_value, float)

RED, BLUE, GREEN, AMBER = "#d64545", "#3b7dd8", "#10a37f", "#d69e2e"


def severity(v, lo, hi):
    return (("low", GREEN) if v < lo else
            ("moderate", AMBER) if v < hi else ("high", RED))


def shap_figure(sv_ac, sv_sg, pred):
    """Two-panel horizontal bar chart of local SHAP values (ue)."""
    fig, axes = plt.subplots(1, 2, figsize=(11, 4.2), dpi=140)
    titles = [(r"$\varepsilon_t$ — AC tensile", sv_ac, BASE[0], pred[0]),
              (r"$\varepsilon_c$ — subgrade compressive", sv_sg, BASE[1],
               pred[1])]
    for ax, (title, sv, base, p) in zip(axes, titles):
        order = np.argsort(np.abs(sv))
        names = [FEATURES[i] for i in order]
        vals = sv[order]
        colors = [RED if v >= 0 else BLUE for v in vals]
        ax.barh(range(len(vals)), vals, color=colors, alpha=0.85)
        ax.axvline(0, color="#999", lw=1)
        ax.set_yticks(range(len(vals)))
        ax.set_yticklabels(names, fontsize=9)
        ax.set_xlabel("SHAP value (με)", fontsize=9)
        ax.set_title(f"{title}\nbaseline {base:.0f} με → prediction "
                     f"{p:.0f} με", fontsize=10)
        ax.grid(alpha=0.3, axis="x")
        for s in ["top", "right"]:
            ax.spines[s].set_visible(False)
    fig.suptitle("Local SHAP — red pushes strain up, blue pushes it down",
                 fontsize=10.5, y=1.02)
    fig.tight_layout()
    return fig


def _gpu(fn):
    """ZeroGPU hardware refuses to start without a @spaces.GPU function.
    Inference itself runs on CPU in <1 s (SHAP ~3 s), so the short duration
    just satisfies the check while keeping queue priority high."""
    return spaces.GPU(duration=30)(fn) if spaces is not None else fn


@_gpu
def predict(d0, d200, d300, d450, d600, d900, d1200, d1500, d1800,
            h_ac, h_base, h_subbase, explain):
    d = [d0, d200, d300, d450, d600, d900, d1200, d1500, d1800]
    h = [h_ac, h_base, h_subbase]
    if any(v is None for v in d + h):
        raise gr.Error("Please fill in all 12 inputs (use 0 only for "
                       "h_Subbase when there is no subbase).")
    d, h = np.array(d, np.float32), np.array(h, np.float32)
    if np.any(d <= 0):
        raise gr.Error("Deflections must be positive (μm at 707 kPa).")
    if h[0] <= 0 or np.any(h < 0):
        raise gr.Error("Thicknesses must be ≥ 0 mm with h_AC > 0.")
    if d[0] < d[-1]:
        raise gr.Error("D0 should exceed D1800 — check the basin order.")

    x = np.concatenate([d, h])[None, :]
    eps_ac, eps_sg = map(float, f(x)[0])

    s_ac, c_ac = severity(eps_ac, 150, 400)
    s_sg, c_sg = severity(eps_sg, 300, 600)
    html = f"""
    <div style="display:flex;gap:14px;flex-wrap:wrap;font-family:system-ui">
      <div style="flex:1;min-width:230px;border:1px solid #e3e3e6;
                  border-radius:12px;padding:14px;background:#fafbfc">
        <div style="font-size:13px;color:#6e6e80">ε<sub>t</sub> — AC tensile
          strain (fatigue criterion)</div>
        <div style="font-size:30px;font-weight:700">{eps_ac:.1f}
          <span style="font-size:15px;color:#6e6e80">με</span>
          <span style="font-size:14px;color:{c_ac}">· {s_ac}</span></div>
        <div style="font-size:12px;color:#6e6e80">bottom of the asphalt layer
        </div>
      </div>
      <div style="flex:1;min-width:230px;border:1px solid #e3e3e6;
                  border-radius:12px;padding:14px;background:#fafbfc">
        <div style="font-size:13px;color:#6e6e80">ε<sub>c</sub> — subgrade
          compressive strain (rutting criterion)</div>
        <div style="font-size:30px;font-weight:700">{eps_sg:.1f}
          <span style="font-size:15px;color:#6e6e80">με</span>
          <span style="font-size:14px;color:{c_sg}">· {s_sg}</span></div>
        <div style="font-size:12px;color:#6e6e80">top of the subgrade</div>
      </div>
    </div>"""

    fig = None
    if explain:
        sv = explainer.shap_values(x, nsamples=NSAMPLES, silent=True)
        sv = np.array(sv)
        if sv.ndim == 3 and sv.shape[-1] == 2:
            sv = np.moveaxis(sv, -1, 0)
        fig = shap_figure(sv[0, 0], sv[1, 0], (eps_ac, eps_sg))
    return html, fig


EXAMPLES = [
    # Thai DOH route 23 (thin structure, unbound base)
    [171.0, 154.5, 145.1, 89.6, 62.8, 47.4, 32.9, 22.6, 17.9,
     100, 150, 300, True],
    # factorial-like softer structure
    [1126.8, 940.1, 815.5, 663.5, 548.0, 390.7, 293.9, 231.7, 189.9,
     100, 200, 300, True],
    # stiff bound-base section
    [120.0, 100.0, 90.0, 75.0, 62.0, 45.0, 33.0, 25.0, 20.0,
     50, 200, 0, True],
]

with gr.Blocks(theme=gr.themes.Soft(primary_hue="emerald"),
               title="DBFT — Pavement Strain Predictor") as demo:
    gr.Markdown(
        "# 🛣️ DBFT — Pavement Strain Predictor\n"
        "Predicts the two critical pavement strains **directly** from a "
        "Falling Weight Deflectometer test — no backcalculation step. "
        "Model: Deflection-Basin Fusion Transformer (~150k parameters), "
        "combined loss λ = 1.0 on an extended layered-elastic surrogate + "
        "7,651 Thai DOH field points. Held-out-route field accuracy: "
        "**R² = 0.96 (AC) / 0.88 (subgrade)**."
    )
    gr.Image(str(ASSETS / "fwd_diagram.png"), show_label=False,
             container=False, interactive=False, show_download_button=False,
             show_fullscreen_button=False)

    gr.Markdown("### Deflection basin — μm, normalized to 707 kPa")
    with gr.Row():
        d_in = [gr.Number(label=lab, precision=1) for lab in
                ["D0", "D200", "D300", "D450", "D600", "D900", "D1200",
                 "D1500", "D1800"]]
    gr.Markdown("### Layer thicknesses — mm (h_Subbase = 0 if no subbase)")
    with gr.Row():
        h_in = [gr.Number(label="h_AC (mm)", precision=0),
                gr.Number(label="h_Base (mm)", precision=0),
                gr.Number(label="h_Subbase (mm)", precision=0)]
        explain_in = gr.Checkbox(value=True,
                                 label="Explain with local SHAP (~3 s)")

    btn = gr.Button("Predict", variant="primary")
    out_html = gr.HTML()
    out_plot = gr.Plot(label="Why? — local SHAP attribution")

    btn.click(predict, inputs=d_in + h_in + [explain_in],
              outputs=[out_html, out_plot])
    gr.Examples(examples=EXAMPLES, inputs=d_in + h_in + [explain_in],
                label="Examples (click a row, then Predict)")
    gr.Markdown(
        "<small>Research prototype — trained on Thai DOH FWD data and "
        "layered-elastic theory at 707 kPa / 150 mm plate. SHAP: "
        "KernelExplainer over the 12 measurable inputs; red bars push the "
        "prediction up, blue bars down.</small>"
    )

if __name__ == "__main__":
    # ssr_mode=False: the experimental SSR (Node.js) path is flaky on
    # HF Spaces — "Stopping Node.js server..." restart loops.
    demo.launch(ssr_mode=False)