Sompote commited on
Commit
fca8237
·
verified ·
1 Parent(s): a71bf9e

Upload 11 files

Browse files
README.md CHANGED
@@ -1,15 +1,45 @@
1
  ---
2
- title: FWD
3
- emoji: 📉
4
- colorFrom: gray
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
- license: apache-2.0
12
- short_description: Falling weight deflectometer calculation
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: DBFT Pavement Strain Predictor
3
+ emoji: 🛣️
4
+ colorFrom: green
5
+ colorTo: gray
6
  sdk: gradio
7
+ sdk_version: 5.24.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
 
11
  ---
12
 
13
+ # DBFT Pavement Strain Predictor
14
+
15
+ Predicts the two critical pavement strains **directly** from a Falling Weight
16
+ Deflectometer (FWD) test — no backcalculation step:
17
+
18
+ - **ε_t** — horizontal tensile strain at the bottom of the asphalt layer
19
+ (fatigue-cracking criterion)
20
+ - **ε_c** — vertical compressive strain at the top of the subgrade
21
+ (rutting criterion)
22
+
23
+ Model: **DBFT (Deflection-Basin Fusion Transformer)**, ~150k parameters,
24
+ trained with a combined surrogate + field loss (λ = 1.0) on an extended
25
+ layered-elastic surrogate (14,174 PyMastic/EVERSTRESS solutions) plus 7,651
26
+ Thai DOH field measurements. Held-out-route field performance:
27
+ **R² = 0.96 (AC) / 0.88 (subgrade)**.
28
+
29
+ Every prediction includes a **local SHAP attribution** (KernelExplainer over
30
+ the 12 field-measurable inputs: 9 deflections D0–D1800 + 3 layer thicknesses)
31
+ showing how each input pushed the prediction away from the model baseline.
32
+
33
+ ## Inputs
34
+
35
+ | Input | Unit | Note |
36
+ |---|---|---|
37
+ | D0 … D1800 | μm | deflection basin, normalized to 707 kPa plate pressure |
38
+ | h_AC, h_Base, h_Subbase | mm | h_Subbase = 0 for structures without a subbase |
39
+
40
+ ## Run locally
41
+
42
+ ```bash
43
+ pip install -r requirements.txt gradio
44
+ python app.py
45
+ ```
__pycache__/app.cpython-311.pyc ADDED
Binary file (14 kB). View file
 
__pycache__/fwd_fusion_transformer.cpython-311.pyc ADDED
Binary file (15.6 kB). View file
 
app.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DBFT Pavement Strain Predictor — Gradio app for Hugging Face Spaces.
2
+
3
+ Predicts the two critical pavement strains directly from an FWD deflection
4
+ basin + layer thicknesses, using the best model from the paper (combined
5
+ loss lambda_f = 1.0, extended surrogate), with a local SHAP explanation
6
+ for every prediction.
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ import gradio as gr
12
+ import matplotlib
13
+ matplotlib.use("Agg")
14
+ import matplotlib.pyplot as plt
15
+ import numpy as np
16
+ import shap
17
+ import torch
18
+
19
+ from fwd_fusion_transformer import DBFT, basin_indices
20
+
21
+ HERE = Path(__file__).resolve().parent
22
+ ASSETS = HERE / "assets"
23
+ TAG = "combined_lam1.0_ext"
24
+ FEATURES = ["D0", "D200", "D300", "D450", "D600", "D900", "D1200", "D1500",
25
+ "D1800", "h_AC", "h_Base", "h_Subbase"]
26
+ NSAMPLES = 200
27
+
28
+ # ---------------- model ----------------
29
+ sc = np.load(ASSETS / f"dbft_{TAG}_scalers.npz")
30
+ model = DBFT()
31
+ model.load_state_dict(torch.load(ASSETS / f"dbft_{TAG}.pt", map_location="cpu"))
32
+ model.eval()
33
+
34
+
35
+ def f(X):
36
+ """X: (n, 12) raw [D0..D1800 um, h_AC..h_Subbase mm] -> (n, 2) strains ue."""
37
+ X = np.asarray(X, np.float32)
38
+ D, H = X[:, :9], X[:, 9:12]
39
+ I = basin_indices(D).astype(np.float32)
40
+ t = lambda a, k: torch.tensor((a - sc[f"{k}_mean"]) / sc[f"{k}_std"],
41
+ dtype=torch.float32)
42
+ with torch.no_grad():
43
+ p = model(t(D, "D"), t(I, "I"), t(H, "H")).numpy()
44
+ return p * sc["Y_std"] + sc["Y_mean"]
45
+
46
+
47
+ bg = np.load(ASSETS / "bg_cache.npz")["bg"]
48
+ explainer = shap.KernelExplainer(f, bg)
49
+ BASE = np.asarray(explainer.expected_value, float)
50
+
51
+ RED, BLUE, GREEN, AMBER = "#d64545", "#3b7dd8", "#10a37f", "#d69e2e"
52
+
53
+
54
+ def severity(v, lo, hi):
55
+ return (("low", GREEN) if v < lo else
56
+ ("moderate", AMBER) if v < hi else ("high", RED))
57
+
58
+
59
+ def shap_figure(sv_ac, sv_sg, pred):
60
+ """Two-panel horizontal bar chart of local SHAP values (ue)."""
61
+ fig, axes = plt.subplots(1, 2, figsize=(11, 4.2), dpi=140)
62
+ titles = [(r"$\varepsilon_t$ — AC tensile", sv_ac, BASE[0], pred[0]),
63
+ (r"$\varepsilon_c$ — subgrade compressive", sv_sg, BASE[1],
64
+ pred[1])]
65
+ for ax, (title, sv, base, p) in zip(axes, titles):
66
+ order = np.argsort(np.abs(sv))
67
+ names = [FEATURES[i] for i in order]
68
+ vals = sv[order]
69
+ colors = [RED if v >= 0 else BLUE for v in vals]
70
+ ax.barh(range(len(vals)), vals, color=colors, alpha=0.85)
71
+ ax.axvline(0, color="#999", lw=1)
72
+ ax.set_yticks(range(len(vals)))
73
+ ax.set_yticklabels(names, fontsize=9)
74
+ ax.set_xlabel("SHAP value (με)", fontsize=9)
75
+ ax.set_title(f"{title}\nbaseline {base:.0f} με → prediction "
76
+ f"{p:.0f} με", fontsize=10)
77
+ ax.grid(alpha=0.3, axis="x")
78
+ for s in ["top", "right"]:
79
+ ax.spines[s].set_visible(False)
80
+ fig.suptitle("Local SHAP — red pushes strain up, blue pushes it down",
81
+ fontsize=10.5, y=1.02)
82
+ fig.tight_layout()
83
+ return fig
84
+
85
+
86
+ def predict(d0, d200, d300, d450, d600, d900, d1200, d1500, d1800,
87
+ h_ac, h_base, h_subbase, explain):
88
+ d = [d0, d200, d300, d450, d600, d900, d1200, d1500, d1800]
89
+ h = [h_ac, h_base, h_subbase]
90
+ if any(v is None for v in d + h):
91
+ raise gr.Error("Please fill in all 12 inputs (use 0 only for "
92
+ "h_Subbase when there is no subbase).")
93
+ d, h = np.array(d, np.float32), np.array(h, np.float32)
94
+ if np.any(d <= 0):
95
+ raise gr.Error("Deflections must be positive (μm at 707 kPa).")
96
+ if h[0] <= 0 or np.any(h < 0):
97
+ raise gr.Error("Thicknesses must be ≥ 0 mm with h_AC > 0.")
98
+ if d[0] < d[-1]:
99
+ raise gr.Error("D0 should exceed D1800 — check the basin order.")
100
+
101
+ x = np.concatenate([d, h])[None, :]
102
+ eps_ac, eps_sg = map(float, f(x)[0])
103
+
104
+ s_ac, c_ac = severity(eps_ac, 150, 400)
105
+ s_sg, c_sg = severity(eps_sg, 300, 600)
106
+ html = f"""
107
+ <div style="display:flex;gap:14px;flex-wrap:wrap;font-family:system-ui">
108
+ <div style="flex:1;min-width:230px;border:1px solid #e3e3e6;
109
+ border-radius:12px;padding:14px;background:#fafbfc">
110
+ <div style="font-size:13px;color:#6e6e80">ε<sub>t</sub> — AC tensile
111
+ strain (fatigue criterion)</div>
112
+ <div style="font-size:30px;font-weight:700">{eps_ac:.1f}
113
+ <span style="font-size:15px;color:#6e6e80">με</span>
114
+ <span style="font-size:14px;color:{c_ac}">· {s_ac}</span></div>
115
+ <div style="font-size:12px;color:#6e6e80">bottom of the asphalt layer
116
+ </div>
117
+ </div>
118
+ <div style="flex:1;min-width:230px;border:1px solid #e3e3e6;
119
+ border-radius:12px;padding:14px;background:#fafbfc">
120
+ <div style="font-size:13px;color:#6e6e80">ε<sub>c</sub> — subgrade
121
+ compressive strain (rutting criterion)</div>
122
+ <div style="font-size:30px;font-weight:700">{eps_sg:.1f}
123
+ <span style="font-size:15px;color:#6e6e80">με</span>
124
+ <span style="font-size:14px;color:{c_sg}">· {s_sg}</span></div>
125
+ <div style="font-size:12px;color:#6e6e80">top of the subgrade</div>
126
+ </div>
127
+ </div>"""
128
+
129
+ fig = None
130
+ if explain:
131
+ sv = explainer.shap_values(x, nsamples=NSAMPLES, silent=True)
132
+ sv = np.array(sv)
133
+ if sv.ndim == 3 and sv.shape[-1] == 2:
134
+ sv = np.moveaxis(sv, -1, 0)
135
+ fig = shap_figure(sv[0, 0], sv[1, 0], (eps_ac, eps_sg))
136
+ return html, fig
137
+
138
+
139
+ EXAMPLES = [
140
+ # Thai DOH route 23 (thin structure, unbound base)
141
+ [171.0, 154.5, 145.1, 89.6, 62.8, 47.4, 32.9, 22.6, 17.9,
142
+ 100, 150, 300, True],
143
+ # factorial-like softer structure
144
+ [1126.8, 940.1, 815.5, 663.5, 548.0, 390.7, 293.9, 231.7, 189.9,
145
+ 100, 200, 300, True],
146
+ # stiff bound-base section
147
+ [120.0, 100.0, 90.0, 75.0, 62.0, 45.0, 33.0, 25.0, 20.0,
148
+ 50, 200, 0, True],
149
+ ]
150
+
151
+ with gr.Blocks(theme=gr.themes.Soft(primary_hue="emerald"),
152
+ title="DBFT — Pavement Strain Predictor") as demo:
153
+ gr.Markdown(
154
+ "# 🛣️ DBFT — Pavement Strain Predictor\n"
155
+ "Predicts the two critical pavement strains **directly** from a "
156
+ "Falling Weight Deflectometer test — no backcalculation step. "
157
+ "Model: Deflection-Basin Fusion Transformer (~150k parameters), "
158
+ "combined loss λ = 1.0 on an extended layered-elastic surrogate + "
159
+ "7,651 Thai DOH field points. Held-out-route field accuracy: "
160
+ "**R² = 0.96 (AC) / 0.88 (subgrade)**."
161
+ )
162
+ gr.Image(str(ASSETS / "fwd_diagram.png"), show_label=False,
163
+ container=False, interactive=False, show_download_button=False,
164
+ show_fullscreen_button=False)
165
+
166
+ gr.Markdown("### Deflection basin — μm, normalized to 707 kPa")
167
+ with gr.Row():
168
+ d_in = [gr.Number(label=lab, precision=1) for lab in
169
+ ["D0", "D200", "D300", "D450", "D600", "D900", "D1200",
170
+ "D1500", "D1800"]]
171
+ gr.Markdown("### Layer thicknesses — mm (h_Subbase = 0 if no subbase)")
172
+ with gr.Row():
173
+ h_in = [gr.Number(label="h_AC (mm)", precision=0),
174
+ gr.Number(label="h_Base (mm)", precision=0),
175
+ gr.Number(label="h_Subbase (mm)", precision=0)]
176
+ explain_in = gr.Checkbox(value=True,
177
+ label="Explain with local SHAP (~3 s)")
178
+
179
+ btn = gr.Button("Predict", variant="primary")
180
+ out_html = gr.HTML()
181
+ out_plot = gr.Plot(label="Why? — local SHAP attribution")
182
+
183
+ btn.click(predict, inputs=d_in + h_in + [explain_in],
184
+ outputs=[out_html, out_plot])
185
+ gr.Examples(examples=EXAMPLES, inputs=d_in + h_in + [explain_in],
186
+ label="Examples (click a row, then Predict)")
187
+ gr.Markdown(
188
+ "<small>Research prototype — trained on Thai DOH FWD data and "
189
+ "layered-elastic theory at 707 kPa / 150 mm plate. SHAP: "
190
+ "KernelExplainer over the 12 measurable inputs; red bars push the "
191
+ "prediction up, blue bars down.</small>"
192
+ )
193
+
194
+ if __name__ == "__main__":
195
+ demo.launch()
assets/bg_cache.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:118d15e4d97b3835806e276ead4cc4f2365a81c23788eece1bccc2de71e2df2f
3
+ size 3138
assets/dbft_combined_lam1.0_ext.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:069a9b079895c97f66aa0ca5ee39567fec21fa97076991bc9071acdb82f1e79f
3
+ size 935386
assets/dbft_combined_lam1.0_ext_scalers.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:04ab6582e463d1a8f59f6c9065c1ac7e51bd56c41924d0ace837dc1496e89d7d
3
+ size 2118
assets/fwd_diagram.png ADDED
fwd_fusion_transformer.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DBFT: Deflection-Basin Fusion Transformer for FWD critical strain prediction
3
+ =============================================================================
4
+ Predicts the two critical pavement responses from Falling Weight Deflectometer
5
+ (FWD) measurements:
6
+ - AC : horizontal tensile strain at the bottom of the asphalt layer
7
+ - Subgrade.2 : vertical compressive strain at the top of the subgrade
8
+
9
+ Inputs (field-measurable only — no layer moduli, avoiding backcalculation):
10
+ - Deflection basin D0..D1800 (9 geophones, treated as a spatial sequence)
11
+ - Layer thicknesses (Asphalt, Base, Subbase)
12
+
13
+ Architecture (novel elements):
14
+ 1. Continuous sensor-offset positional encoding: Fourier features of the
15
+ physical geophone offset (mm), so the model knows the true basin geometry
16
+ and generalizes to arbitrary sensor arrays.
17
+ 2. Physics-informed basin tokens: SCI, BDI, BCI, AREA, AUPP indices are
18
+ embedded as extra tokens alongside raw deflections.
19
+ 3. Thickness encoder branch producing (a) memory tokens and (b) FiLM
20
+ (feature-wise linear modulation) parameters applied to the basin tokens.
21
+ 4. Strain-query transformer decoder: one learnable query per target strain
22
+ cross-attends over the fused basin+thickness memory (set-to-vector
23
+ decoding), giving per-target attention maps for interpretability.
24
+ """
25
+
26
+ import math
27
+ import numpy as np
28
+ import pandas as pd
29
+ import torch
30
+ import torch.nn as nn
31
+
32
+ SENSOR_OFFSETS_MM = np.array([0, 200, 300, 450, 600, 900, 1200, 1500, 1800], dtype=np.float32)
33
+ DEFLECTION_COLS = ["D0", "D200", "D300", "D450", "D600", "D900", "D1200", "D1500", "D1800"]
34
+ THICKNESS_COLS = ["Asphalt", "Base", "Subbase"]
35
+ TARGET_COLS = {"AC_tensile": "AC", "Subgrade_compressive": "Subgrade.2"}
36
+
37
+
38
+ # ----------------------------------------------------------------------------
39
+ # Physics-informed deflection basin indices
40
+ # ----------------------------------------------------------------------------
41
+ def basin_indices(D: np.ndarray) -> np.ndarray:
42
+ """D: (N, 9) deflections at SENSOR_OFFSETS_MM. Returns (N, 5) indices."""
43
+ d0, d200, d300, d450, d600, d900, d1200, d1500, d1800 = D.T
44
+ sci = d0 - d300 # Surface Curvature Index (AC condition)
45
+ bdi = d300 - d600 # Base Damage Index
46
+ bci = d600 - d900 # Base Curvature Index (subbase/subgrade)
47
+ # AASHTO AREA parameter (normalized basin area, first 4 sensors ~ 0-600 mm here)
48
+ area = 6.0 * (1 + 2 * d300 / d0 + 2 * d600 / d0 + d900 / d0)
49
+ # AUPP: Area Under Pavement Profile — strongly correlated with AC tensile strain
50
+ aupp = (5 * d0 - 2 * d300 - 2 * d600 - d900) / 2.0
51
+ return np.stack([sci, bdi, bci, area, aupp], axis=1)
52
+
53
+
54
+ # ----------------------------------------------------------------------------
55
+ # Continuous positional encoding of physical sensor offsets
56
+ # ----------------------------------------------------------------------------
57
+ class SensorOffsetEncoding(nn.Module):
58
+ """Fourier features of the physical geophone offset (mm), projected to d_model.
59
+ Unlike integer positional encoding, this respects the true non-uniform
60
+ basin geometry (0,200,300,450,...) and supports arbitrary sensor arrays."""
61
+
62
+ def __init__(self, d_model: int, n_freq: int = 16, max_offset: float = 2000.0):
63
+ super().__init__()
64
+ freqs = torch.exp(torch.linspace(math.log(1.0), math.log(max_offset), n_freq))
65
+ self.register_buffer("freqs", freqs)
66
+ self.proj = nn.Linear(2 * n_freq, d_model)
67
+
68
+ def forward(self, offsets_mm: torch.Tensor) -> torch.Tensor:
69
+ # offsets_mm: (S,) -> (S, d_model)
70
+ x = offsets_mm.unsqueeze(-1) / self.freqs # (S, n_freq)
71
+ feats = torch.cat([torch.sin(x), torch.cos(x)], dim=-1)
72
+ return self.proj(feats)
73
+
74
+
75
+ class FiLM(nn.Module):
76
+ """Feature-wise linear modulation of basin tokens by the thickness code."""
77
+
78
+ def __init__(self, cond_dim: int, d_model: int):
79
+ super().__init__()
80
+ self.to_gamma_beta = nn.Linear(cond_dim, 2 * d_model)
81
+
82
+ def forward(self, tokens: torch.Tensor, cond: torch.Tensor) -> torch.Tensor:
83
+ gamma, beta = self.to_gamma_beta(cond).chunk(2, dim=-1) # (B, d_model) each
84
+ return tokens * (1 + gamma.unsqueeze(1)) + beta.unsqueeze(1)
85
+
86
+
87
+ # ----------------------------------------------------------------------------
88
+ # DBFT model
89
+ # ----------------------------------------------------------------------------
90
+ class DBFT(nn.Module):
91
+ def __init__(
92
+ self,
93
+ d_model: int = 64,
94
+ n_heads: int = 4,
95
+ n_encoder_layers: int = 3,
96
+ n_decoder_layers: int = 2,
97
+ d_ff: int = 128,
98
+ dropout: float = 0.10,
99
+ n_targets: int = 2,
100
+ n_thickness: int = 3,
101
+ n_indices: int = 5,
102
+ use_film: bool = True,
103
+ use_indices: bool = True,
104
+ use_thickness_memory: bool = True,
105
+ ):
106
+ super().__init__()
107
+ self.use_film = use_film
108
+ self.use_indices = use_indices
109
+ self.use_thickness_memory = use_thickness_memory
110
+
111
+ # --- basin branch ---
112
+ self.deflection_embed = nn.Linear(1, d_model)
113
+ self.pos_enc = SensorOffsetEncoding(d_model)
114
+ self.register_buffer("offsets", torch.tensor(SENSOR_OFFSETS_MM))
115
+
116
+ # physics-informed index tokens
117
+ self.index_embed = nn.Linear(1, d_model)
118
+ self.index_type_embed = nn.Parameter(torch.randn(n_indices, d_model) * 0.02)
119
+
120
+ enc_layer = nn.TransformerEncoderLayer(
121
+ d_model, n_heads, d_ff, dropout, activation="gelu",
122
+ batch_first=True, norm_first=True,
123
+ )
124
+ self.basin_encoder = nn.TransformerEncoder(enc_layer, n_encoder_layers)
125
+
126
+ # --- thickness branch ---
127
+ self.thickness_encoder = nn.Sequential(
128
+ nn.Linear(n_thickness, d_model), nn.GELU(),
129
+ nn.Linear(d_model, d_model), nn.GELU(),
130
+ )
131
+ self.film = FiLM(d_model, d_model)
132
+ self.thickness_token_proj = nn.Linear(d_model, d_model)
133
+
134
+ # --- fusion decoder: learnable strain queries ---
135
+ self.strain_queries = nn.Parameter(torch.randn(n_targets, d_model) * 0.02)
136
+ dec_layer = nn.TransformerDecoderLayer(
137
+ d_model, n_heads, d_ff, dropout, activation="gelu",
138
+ batch_first=True, norm_first=True,
139
+ )
140
+ self.fusion_decoder = nn.TransformerDecoder(dec_layer, n_decoder_layers)
141
+
142
+ self.head = nn.Sequential(
143
+ nn.LayerNorm(d_model), nn.Linear(d_model, d_model), nn.GELU(),
144
+ nn.Linear(d_model, 1),
145
+ )
146
+
147
+ def forward(self, deflections, indices, thickness):
148
+ """deflections (B,9), indices (B,5), thickness (B,3) — all standardized."""
149
+ B = deflections.shape[0]
150
+
151
+ # basin tokens with continuous offset encoding
152
+ tok = self.deflection_embed(deflections.unsqueeze(-1)) # (B,9,d)
153
+ tok = tok + self.pos_enc(self.offsets).unsqueeze(0) # (B,9,d)
154
+
155
+ if self.use_indices:
156
+ idx_tok = self.index_embed(indices.unsqueeze(-1)) + self.index_type_embed
157
+ tok = torch.cat([tok, idx_tok], dim=1) # (B,14,d)
158
+
159
+ # thickness conditioning
160
+ t_code = self.thickness_encoder(thickness) # (B,d)
161
+ if self.use_film:
162
+ tok = self.film(tok, t_code)
163
+
164
+ memory = self.basin_encoder(tok) # (B,S,d)
165
+ if self.use_thickness_memory:
166
+ memory = torch.cat([memory, self.thickness_token_proj(t_code).unsqueeze(1)], dim=1)
167
+
168
+ queries = self.strain_queries.unsqueeze(0).expand(B, -1, -1) # (B,2,d)
169
+ decoded = self.fusion_decoder(queries, memory) # (B,2,d)
170
+ return self.head(decoded).squeeze(-1) # (B,2)
171
+
172
+ @torch.no_grad()
173
+ def attention_map(self, deflections, indices, thickness):
174
+ """Cross-attention weights of each strain query over memory tokens
175
+ (last decoder layer), for interpretability."""
176
+ self.eval()
177
+ B = deflections.shape[0]
178
+ tok = self.deflection_embed(deflections.unsqueeze(-1))
179
+ tok = tok + self.pos_enc(self.offsets).unsqueeze(0)
180
+ if self.use_indices:
181
+ idx_tok = self.index_embed(indices.unsqueeze(-1)) + self.index_type_embed
182
+ tok = torch.cat([tok, idx_tok], dim=1)
183
+ t_code = self.thickness_encoder(thickness)
184
+ if self.use_film:
185
+ tok = self.film(tok, t_code)
186
+ memory = self.basin_encoder(tok)
187
+ if self.use_thickness_memory:
188
+ memory = torch.cat([memory, self.thickness_token_proj(t_code).unsqueeze(1)], dim=1)
189
+
190
+ x = self.strain_queries.unsqueeze(0).expand(B, -1, -1)
191
+ attn_out = None
192
+ for i, layer in enumerate(self.fusion_decoder.layers):
193
+ q = layer.norm1(x)
194
+ x = x + layer.dropout1(layer.self_attn(q, q, q, need_weights=False)[0])
195
+ q2 = layer.norm2(x)
196
+ out, w = layer.multihead_attn(q2, memory, memory,
197
+ need_weights=True, average_attn_weights=True)
198
+ if i == len(self.fusion_decoder.layers) - 1:
199
+ attn_out = w # (B, n_targets, S_mem)
200
+ x = x + layer.dropout2(out)
201
+ x = x + layer._ff_block(layer.norm3(x))
202
+ return attn_out
203
+
204
+
205
+ # ----------------------------------------------------------------------------
206
+ # Data loading / preprocessing
207
+ # ----------------------------------------------------------------------------
208
+ def load_dataset(path="strain_result.xlsx"):
209
+ df = pd.read_csv(path) if str(path).endswith(".csv") else pd.read_excel(path)
210
+ D = df[DEFLECTION_COLS].to_numpy(np.float32)
211
+ H = df[THICKNESS_COLS].to_numpy(np.float32)
212
+ Y = df[list(TARGET_COLS.values())].to_numpy(np.float32)
213
+ I = basin_indices(D).astype(np.float32)
214
+ return D, I, H, Y, df
215
+
216
+
217
+ class Standardizer:
218
+ def fit(self, x):
219
+ self.mean = x.mean(axis=0, keepdims=True)
220
+ self.std = x.std(axis=0, keepdims=True) + 1e-8
221
+ return self
222
+
223
+ def transform(self, x):
224
+ return (x - self.mean) / self.std
225
+
226
+ def inverse(self, x):
227
+ return x * self.std + self.mean
make_diagram.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate assets/fwd_diagram.png — schematic of the FWD test for the app."""
2
+
3
+ import numpy as np
4
+ import matplotlib
5
+ matplotlib.use("Agg")
6
+ import matplotlib.pyplot as plt
7
+ from matplotlib.patches import Rectangle, FancyArrowPatch
8
+
9
+ plt.rcParams.update({"font.size": 11, "figure.dpi": 200})
10
+
11
+ OFFSETS = [0, 200, 300, 450, 600, 900, 1200, 1500, 1800]
12
+
13
+ fig, ax = plt.subplots(figsize=(11, 4.4))
14
+ ax.set_xlim(-260, 2050)
15
+ ax.set_ylim(-165, 105)
16
+ ax.axis("off")
17
+
18
+ # layers
19
+ layers = [(0, -34, "#4a4a52", "AC — h$_{AC}$", "white"),
20
+ (-34, -76, "#c9a86a", "Base — h$_{Base}$", "#4d3d18"),
21
+ (-76, -124, "#e3d5b8", "Subbase — h$_{Subbase}$", "#6d5f3a"),
22
+ (-124, -165, "#a4b58c", "Subgrade (semi-infinite)", "#33402a")]
23
+ for top, bot, c, lab, tc in layers:
24
+ ax.add_patch(Rectangle((-260, bot), 2310, top - bot, fc=c, ec="none"))
25
+ ax.text(2020, (top + bot) / 2, lab, ha="right", va="center",
26
+ fontsize=10.5, color=tc, fontweight="bold")
27
+
28
+ # falling weight + plate
29
+ ax.add_patch(Rectangle((-55, 62), 110, 34, fc="#8b95a5", ec="#5d6673"))
30
+ ax.add_patch(FancyArrowPatch((0, 60), (0, 12), arrowstyle="-|>",
31
+ mutation_scale=18, lw=2, color="#333",
32
+ linestyle=(0, (4, 3))))
33
+ ax.text(0, 100, "falling weight — 707 kPa", ha="center", fontsize=10.5)
34
+ ax.add_patch(Rectangle((-150, 0), 300, 9, fc="#3d4451", ec="none"))
35
+ ax.text(-170, 14, "plate r = 150 mm", ha="right", fontsize=9.5)
36
+
37
+ # geophones + basin
38
+ basin_y = [-30 * np.exp(-x / 550) for x in OFFSETS]
39
+ ax.plot(OFFSETS, basin_y, "--", color="#ff6b6b", lw=1.8, zorder=5)
40
+ ax.text(700, -12, "deflection basin", color="#e04848", fontsize=9.5)
41
+ for x in OFFSETS:
42
+ ax.plot(x, 14, "v", ms=8, color="#10a37f" if x == 0 else "#5436da",
43
+ zorder=6)
44
+ ax.text(x, 26, f"D{x}", ha="center", fontsize=8.6)
45
+ ax.text(1050, 55, "9 geophones record the deflection basin (μm)",
46
+ ha="center", fontsize=10, color="#444")
47
+
48
+ # strain markers
49
+ ax.plot(0, -32, "o", ms=10, mfc="none", mec="#ff8f8f", mew=2, zorder=6)
50
+ ax.text(50, -28, r"$\varepsilon_t$ — AC tensile strain (fatigue)",
51
+ fontsize=10.5, color="#ffb3b3", fontweight="bold")
52
+ ax.plot(0, -126, "o", ms=10, mfc="none", mec="#2e6bc4", mew=2, zorder=6)
53
+ ax.text(50, -140, r"$\varepsilon_c$ — subgrade compressive strain (rutting)",
54
+ fontsize=10.5, color="#1d4e94", fontweight="bold")
55
+
56
+ fig.tight_layout()
57
+ fig.savefig("assets/fwd_diagram.png", bbox_inches="tight",
58
+ facecolor="white")
59
+ print("wrote assets/fwd_diagram.png")
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cpu
2
+ torch>=2.2
3
+ numpy>=1.24
4
+ shap>=0.45
5
+ scikit-learn>=1.3
6
+ pandas>=2.0
7
+ matplotlib>=3.7