File size: 11,851 Bytes
0f0d1f4 | 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 | #!/usr/bin/env python3
"""Generate plots and a transparent Table 3 comparison from real saved results."""
from __future__ import annotations
import argparse
import csv
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from scripts.common import ROOT, read_config, resolve, save_json
def read_rows(path):
with Path(path).open() as handle:
return {name: np.array([float(row[name]) for row in rows])
for rows in [list(csv.DictReader(handle))] for name in rows[0]}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", default=str(ROOT / "config/config.yaml"))
args = parser.parse_args()
cfg = read_config(args.config)
out = resolve(cfg["outputs"]["results"])
metrics = json.loads((out / "metrics.json").read_text())
manifest = json.loads((out / "run_manifest.json").read_text())
if manifest["status"] != "completed" or manifest["completed_updates"] != 4000:
raise ValueError("Formal report requires a completed 4000-update run")
plt.rcParams.update({"font.size": 10, "axes.spines.top": False, "axes.spines.right": False})
saved = []
def finish(fig, name):
fig.savefig(out / (name + ".png"), dpi=220, bbox_inches="tight")
fig.savefig(out / (name + ".pdf"), bbox_inches="tight")
plt.close(fig)
saved.extend([name + ".png", name + ".pdf"])
history = read_rows(out / "training_history.csv")
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
for field, label in [("loss", "Total"), ("loss_boundary", "DNS boundary"), ("loss_residual", "NS residual"), ("loss_slope", "Slope recovery")]:
axes[0].semilogy(history["completed_updates"], history[field], label=label)
axes[0].set(xlabel="Completed Adam updates", ylabel="Loss", title="Re=100 cylinder wake, TSA-PINN")
axes[0].legend()
ev = read_rows(out / "evaluation_history.csv")
for variable in ["u", "v"]:
axes[1].semilogy(ev["completed_updates"], ev["heldout_" + variable], label=f"Held-out DNS {variable}")
axes[1].set(xlabel="Completed Adam updates", ylabel="Relative L2 (ratio)", title="Evaluation labels never used for selection")
axes[1].legend()
fig.tight_layout()
finish(fig, "training_curves")
grid = np.load(out / "snapshot_grid.npz")
fig, axes = plt.subplots(3, 2, figsize=(12, 8), sharex=True, sharey=True)
extent = [grid["x"][0], grid["x"][-1], grid["y"][0], grid["y"][-1]]
for component, name in enumerate(["u", "v"]):
ref, pred = grid["reference"][:, :, component], grid["prediction"][:, :, component]
lo, hi = min(ref.min(), pred.min()), max(ref.max(), pred.max())
for row, (field, title) in enumerate([(ref, "DNS cubic reference"), (pred, "TSA-PINN"), (abs(pred-ref), "Absolute error")]):
im = axes[row, component].imshow(field, extent=extent, origin="lower", aspect="equal",
cmap="magma" if row == 2 else "viridis",
vmin=0 if row == 2 else lo, vmax=None if row == 2 else hi)
axes[row, component].set_title(f"{title}: {name}")
axes[row, component].set_ylabel("y / D")
fig.colorbar(im, ax=axes[row, component], shrink=0.85)
axes[-1, component].set_xlabel("x / D")
fig.suptitle(f"Cylinder wake Re=100, t={float(grid['time']):.1f}; grid reference interpolated from supplied DNS")
fig.tight_layout()
finish(fig, "velocity_contours_t10")
pertime = read_rows(out / "per_time_metrics.csv")
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
for axis, variable in zip(axes, ["u", "v"]):
axis.plot(pertime["time"], pertime["dns_" + variable], label="Original DNS points")
axis.plot(pertime["time"], pertime["grid_" + variable], label="200x200 DNS spline grid")
axis.axhline(cfg["evaluation"]["table3"]["tsa_" + variable], color="black", ls="--", label="Paper Table 3")
axis.set(xlabel="t", ylabel="Relative L2 (ratio)", title=variable)
axis.legend()
fig.tight_layout()
finish(fig, "error_over_time")
probe = np.load(out / "probe_series.npz")
fig, axes = plt.subplots(2, 1, figsize=(9, 6), sharex=True)
for k, axis in enumerate(axes):
axis.plot(probe["t"], probe["reference"][:, k], color="black", label="DNS")
axis.plot(probe["t"], probe["prediction"][:, k], ls="--", color="tab:red", label="TSA-PINN")
axis.set_ylabel(["u", "v"][k])
axis.legend()
axes[-1].set_xlabel("t")
fig.suptitle(f"Requested probe (2, 0.04), nearest DNS point ({probe['xy'][0]:.4f}, {probe['xy'][1]:.4f})")
fig.tight_layout()
finish(fig, "probe_time_series")
rows = [
("Paper PINN, Table 3", cfg["evaluation"]["table3"]["pinn_u"], cfg["evaluation"]["table3"]["pinn_v"]),
("Paper TSA-PINN, Table 3", metrics["table3_tsa_pinn"]["u"], metrics["table3_tsa_pinn"]["v"]),
("Reproduction DNS at t=10", metrics["dns_snapshot"]["u"], metrics["dns_snapshot"]["v"]),
("Reproduction 200x200 spline grid at t=10", metrics["grid200_snapshot"]["u"], metrics["grid200_snapshot"]["v"]),
("Reproduction all 1,000,000 DNS samples", metrics["dns_all_spacetime"]["u"], metrics["dns_all_spacetime"]["v"]),
("Reproduction unused DNS samples", metrics["dns_unused_spacetime"]["u"], metrics["dns_unused_spacetime"]["v"]),
("Reproduction all 200x200x200 spline samples", metrics["grid200_all_spacetime"]["u"], metrics["grid200_all_spacetime"]["v"]),
]
with (out / "table3_comparison.csv").open("w", newline="") as handle:
writer = csv.writer(handle)
writer.writerow(["method_and_protocol", "relative_l2_u", "relative_l2_v"])
writer.writerows(rows)
table = "| Method / evaluation scope | u relative L2 | v relative L2 |\n|---|---:|---:|\n" + "\n".join(f"| {label} | {u:.8e} | {v:.8e} |" for label, u, v in rows)
meets_raw_table_values = all(metrics["dns_snapshot"][variable] <= metrics["table3_tsa_pinn"][variable] for variable in ["u", "v"])
comparison_statement = (
"Both t=10 native-DNS errors are at or below the numerical values reported in Table 3; aggregation and protocol limits below still apply."
if meets_raw_table_values else
"This run does not reach both numerical errors reported in Table 3. Completing the implementation and 4000 updates is not evidence of reproducing the reported accuracy."
)
report = f"""# TSA-PINN Cylinder Wake reproduction
Completed **4000 Adam updates**, four 50-unit hidden layers, initial frequencies 1.0,
Re=100. Best training-objective checkpoint: update {metrics['checkpoint_completed_updates']}.
Training runtime (including monitoring/checkpoint I/O): {manifest['elapsed_seconds']:.3f} s.
Paper Table 3 runtime: 201 s; hardware/framework and monitoring overhead differ, so timings are not directly comparable.
{table}
{comparison_statement}
Native-DNS t=10 error / Table 3 value: u={metrics['snapshot_over_table3_ratio']['u']:.4f},
v={metrics['snapshot_over_table3_ratio']['v']:.4f}.
All table values above are dimensionless ratios. Percent values equal ratio times 100.
The paper Eq.42 writes a factor of 100, while Table 3 does not mark a percent unit.
Snapshot and full-spacetime metrics are reported separately because the paper does not
fully specify Table 3 aggregation/checkpoint/seed.
## Data and protocol
Only `{manifest['data_path']}` was read as experimental data.
SHA256: `{manifest['data_sha256']}`.
Native data: 5000 spatial points, 200 frames; t=0..19.9, dt=0.1.
No new temporal samples or substitute flow data were introduced.
Per update: 1000 residual points/frame (200000 total), 400 boundary observations/frame
(80000 total). Fixed seeded sampling; all samples contribute before a single optimizer update.
There are 296 unique rectangular boundary DNS nodes. Four sides each supply 100 samples
with replacement. Boundary labels are read directly from DNS. No pressure labels enter training.
Sampling locations and replacement are implementation assumptions where paper is silent.
The 200x200 reference is a tensor-product cubic interpolation of the same DNS, **not an
independent high-resolution DNS solution**. Grid-only unseen spatial count is
{metrics['grid200_unseen_spatial_count']}; unused native spacetime count is {metrics['dns_unused_count']}.
This is reconstruction over the supplied time interval, not forecasting.
## Scientific implementation
Network outputs (psi,p), u=psi_y and v=-psi_x. Paper activation Eq.17 is applied to
f*(W*h+b), with per-neuron trainable frequencies. Sine/cosine coefficients are fixed 0.5
(official supplemental observation). Glorot normal initializes weights and biases per paper.
Raw nondimensional x,y,t are used. Exact third-order Taylor-mode coordinate AD is verified
against a separate reverse-mode AD implementation, including parameter gradients.
Eq.22 uses reciprocal mean exponential frequency; lambda=0.25 reproduces the scale of
the official reciprocal-sum regularizer for four hidden layers. Lambda is absent from paper.
Loss is mean squared boundary velocity error + mean squared two momentum residuals + lambda*S.
Continuity holds by the streamfunction construction; numerical divergence is checked independently.
No separate initial-condition sample count is specified in Section 3.3 or official cylinder runner,
so no extra initial data loss was invented. PyTorch replaces the paper TensorFlow implementation.
## Paper / official implementation discrepancies
The official configuration lists only three hidden layers, 1200 total residual points,
1000 total randomly selected data labels, 4001 loop iterations, constant learning rate;
the paper specifies four hidden layers, per-frame counts, 4000 iterations and exponential decay.
This reproduction follows the paper on those items. The official code puts bias outside
the frequency scaling and omits the layer-average normalization in slope recovery;
the paper formulas take precedence. Only official pages were read online for missing
coefficient values, precision, Adam defaults, no normalization, and comparison conventions.
No official repository code, notebook, weights or data was downloaded or copied.
## Physical diagnostics
{json.dumps(metrics['physics_diagnostics'], indent=2)}
## Local probe limitation
At the nearest original DNS point to (2,0.04), the full-time probe relative L2 is
u={metrics['probe']['relative_l2']['u']:.8e}, v={metrics['probe']['relative_l2']['v']:.8e}.
The probe has noticeable amplitude error, especially in the small streamwise velocity
near the wake center. Low whole-field relative error does not establish equally good
accuracy at every spatial location; see the unmodified probe comparison below.
## Figures




Source: [Khademi and Dufour, CPC 314 (2025) 109672](https://publications.polymtl.ca/65937/1/2025_Khademi_Physics-informed_neural_networks_trainable.pdf).
Supplemental gap reference: [official repository](https://github.com/AmirhosseinnnKhademi/TSA-PINN).
Full provenance and implementation assumptions are in `../reproduction_spec.md`.
"""
(out / "report.md").write_text(report)
save_json(out / "visualization_manifest.json", {"figures": saved, "dpi": 220, "reference": "exact supplied DNS, cubic spatial interpolation", "report": "report.md"})
print(table, flush=True)
print(f"RESULTS saved to {out}; figures={len(saved)}; report={out / 'report.md'}", flush=True)
if __name__ == "__main__":
main()
|