Export displacement fields for the chain tops, add this repository's own site with a field viewer, and leave the comparison to the challenge repository
12 changed files+385−46
.mochi/workflows/results.ymlmodified+7−14View file
@@ -1,32 +1,25 @@
1-name: Results
1+name: Validate and publish
22 on:
33 push:
44 workflow_dispatch:
55 concurrency:
6- group: results
6+ group: site
77 cancel-in-progress: true
88 jobs:
9- results:
9+ site:
1010 runs-on: ubuntu-latest
1111 steps:
1212 - uses: actions/checkout@v4
13- - name: Check out the challenge repository
14- uses: actions/checkout@v4
15- with:
16- repository: challenges/string
17- ref: main
18- path: challenge-repo
13+ - name: Get the challenge repository (specifications and tools)
14+ run: git clone --depth 1 --branch main https://vault1.magland.org/challenges/string challenge-repo
1915 - uses: actions/setup-python@v5
2016 with:
2117 python-version: '3.12'
2218 - run: pip install ./challenge-repo
2319 - name: Validate submissions
2420 run: challenge-tools validate --challenges challenge-repo/challenges --solutions solutions
25- - name: Build results site
26- run: >
27- challenge-tools build --challenges challenge-repo/challenges --solutions solutions --out site
28- --challenge-repo-url https://vault1.magland.org/challenges/string
29- --solutions-repo-url https://vault1.magland.org/challenges/string-solutions
21+ - name: Build this repository's site
22+ run: python web/build.py --out site
3023 - if: github.ref == 'refs/heads/main'
3124 uses: actions/configure-pages@v4
3225 - if: github.ref == 'refs/heads/main'
README.mdmodified+25−9View file
@@ -1,28 +1,44 @@
11 # Solutions to the string challenges
22
3-This repository collects solutions to the challenges defined in [challenges/string](https://vault1.magland.org/challenges/string). The results, with errors, timings, audio players, and links, are published at [challenges/string-solutions/site](https://vault1.magland.org/challenges/string-solutions/site/) and regenerated by a workflow on every push to `main`.
3+This repository holds solutions to the challenges defined in [challenges/string](https://vault1.magland.org/challenges/string). It is one of possibly several solution repositories registered with that challenge repository, which clones each of them, compares every solution against a common reference, and publishes the comparison at [challenges/string/site](https://vault1.magland.org/challenges/string/site/). This repository's own site, at [challenges/string-solutions/site](https://vault1.magland.org/challenges/string-solutions/site/), lists its submissions with audio players and animates the exported displacement fields.
44
55 ## Layout
66
77 ```
88 solutions/<challenge-id>/<submission-id>.json one submission: metadata and one or more solutions
9-solutions/<challenge-id>/<submission-id>/*.wav the output files, referenced from the JSON
10-solvers/ source code of the example submissions
9+solutions/<challenge-id>/<submission-id>/*.wav the scored output files, referenced from the JSON
10+solutions/<challenge-id>/<submission-id>/*.h5 optional displacement fields for visualization
11+solvers/ source code of the submissions
12+web/ this repository's site (built by web/build.py)
1113 ```
1214
13-## Submitting
15+## Adding a submission to this repository
1416
15-1. Read the challenge specification, for example [string-pluck-v1](https://vault1.magland.org/challenges/string/blob/main/challenges/string-pluck-v1/README.md), and produce the output file exactly as specified (format, sample rate, length, scale).
16-2. Choose a submission ID (lowercase letters, digits, hyphens) and write `solutions/<challenge-id>/<submission-id>.json` following the [schema](https://vault1.magland.org/challenges/string/blob/main/schema/solution.schema.json). The two files in `solutions/string-pluck-v1/` are complete examples.
17-3. Put the wav files in `solutions/<challenge-id>/<submission-id>/` and reference them by relative path, or host them elsewhere and reference them by URL. Files over 1 MiB must be pushed with git rather than written through the web interface.
18-4. If your submission contains several solutions of known relative accuracy (for example, a sequence of increasing resolution), record that in `more_accurate_than` on each solution. This is what allows the reference solution to be chosen automatically; see the challenge repository README for the rule.
19-5. Open a pull request. The workflow validates the files; once merged, the results site is rebuilt.
17+1. Read the challenge specification, for example [string-pluck-v1](https://vault1.magland.org/challenges/string/blob/main/challenges/string-pluck-v1/README.md), and produce the output exactly as specified.
18+2. Choose a submission ID (lowercase letters, digits, hyphens) and write `solutions/<challenge-id>/<submission-id>.json` following the [schema](https://vault1.magland.org/challenges/string/blob/main/schema/solution.schema.json). The existing files are complete examples.
19+3. Put the wav files (and optional field exports) in `solutions/<challenge-id>/<submission-id>/`. Files over 1 MiB must be pushed with git rather than written through the web interface.
20+4. If the submission contains several solutions of known relative accuracy, record that in `more_accurate_than`; this is what lets the reference be chosen automatically.
21+5. Open a pull request. The workflow validates the files and, on `main`, rebuilds this site. The comparison on the challenge repository's site is rebuilt when that repository's workflow next runs (on a push there, or by dispatching it).
2022
2123 Timing is self-reported wall-clock time for producing the output signal, excluding file I/O, on the hardware named in the `hardware` field.
2224
25+## Starting a solution repository of your own
26+
27+Any public repository with the same `solutions/` layout can be registered by a pull request to the challenge repository adding one entry to `registry/solution-repos.json`. What else the repository contains, including its own visualizations, is up to its authors.
28+
2329 ## Validating locally
2430
2531 ```
2632 pip install git+https://vault1.magland.org/challenges/string
2733 challenge-tools validate --challenges <path-to-string-repo>/challenges --solutions solutions
34+python web/build.py --out site
2835 ```
36+
37+## Regenerating the example submissions
38+
39+```
40+python solvers/modal/modal_string.py <string-repo>/challenges/string-pluck-v1/challenge.json solutions/string-pluck-v1/modal-series --fields 1024 4 8 16 32 64 256 1024
41+python solvers/fd/fd_string.py <string-repo>/challenges/string-pluck-v1/challenge.json solutions/string-pluck-v1/fd-second-order --fields 16 1 2 4 8 16
42+```
43+
44+Each solver prints per-solution metadata as JSON, from which the submission files are assembled.
solutions/string-pluck-v1/fd-second-order.jsonmodified+11−6View file
@@ -1,12 +1,13 @@
11 {
22 "schema_version": 1,
3- "submission_id": "fd-second-order",
43 "challenge_id": "string-pluck-v1",
54 "submitter": {
65 "name": "Jeremy Magland",
76 "email": "jmagland@flatironinstitute.org"
87 },
98 "date": "2026-09-17",
9+ "viz_url": "https://vault1.magland.org/challenges/string-solutions/site/",
10+ "submission_id": "fd-second-order",
1011 "description": "Second-order finite differences: leapfrog in time for the wave and frequency-independent loss terms, with the Kelvin-Voigt term centered in time (implicit, tridiagonal solve per step). Fixed Courant number 0.9, so deliberately dispersive; time step 1/(48000 m). Ordered by increasing oversampling factor m.",
1112 "code_url": "https://vault1.magland.org/challenges/string-solutions/blob/main/solvers/fd/fd_string.py",
1213 "solutions": [
@@ -21,7 +22,7 @@
2122 "dt_s": 2.0833333333333333e-05,
2223 "courant": 0.898333
2324 },
24- "timing_seconds": 2.033,
25+ "timing_seconds": 2.968,
2526 "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
2627 "more_accurate_than": []
2728 },
@@ -36,7 +37,7 @@
3637 "dt_s": 1.0416666666666666e-05,
3738 "courant": 0.898333
3839 },
39- "timing_seconds": 4.565,
40+ "timing_seconds": 5.195,
4041 "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
4142 "more_accurate_than": [
4243 "fd-m1"
@@ -53,7 +54,7 @@
5354 "dt_s": 5.208333333333333e-06,
5455 "courant": 0.899479
5556 },
56- "timing_seconds": 11.802,
57+ "timing_seconds": 12.217,
5758 "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
5859 "more_accurate_than": [
5960 "fd-m2"
@@ -70,7 +71,7 @@
7071 "dt_s": 2.6041666666666666e-06,
7172 "courant": 0.899479
7273 },
73- "timing_seconds": 31.794,
74+ "timing_seconds": 32.797,
7475 "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
7576 "more_accurate_than": [
7677 "fd-m4"
@@ -87,8 +88,12 @@
8788 "dt_s": 1.3020833333333333e-06,
8889 "courant": 0.899766
8990 },
90- "timing_seconds": 101.051,
91+ "timing_seconds": 117.533,
9192 "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
93+ "fields": {
94+ "path": "fd-second-order/fd-m16.h5",
95+ "format": "hdf5"
96+ },
9297 "more_accurate_than": [
9398 "fd-m8"
9499 ]
solutions/string-pluck-v1/fd-second-order/fd-m16.h5added+0−0View file
Binary file not shown.
solutions/string-pluck-v1/modal-series.jsonmodified+13−8View file
@@ -1,12 +1,13 @@
11 {
22 "schema_version": 1,
3- "submission_id": "modal-series",
43 "challenge_id": "string-pluck-v1",
54 "submitter": {
65 "name": "Jeremy Magland",
76 "email": "jmagland@flatironinstitute.org"
87 },
98 "date": "2026-09-17",
9+ "viz_url": "https://vault1.magland.org/challenges/string-solutions/site/",
10+ "submission_id": "modal-series",
1011 "description": "Truncated modal series from the appendix of the challenge README, evaluated directly at the sample instants. Exact up to truncation and floating point; the chain of truncation orders is ordered by increasing number of modes.",
1112 "code_url": "https://vault1.magland.org/challenges/string-solutions/blob/main/solvers/modal/modal_string.py",
1213 "solutions": [
@@ -17,7 +18,7 @@
1718 "parameters": {
1819 "n_modes": 4
1920 },
20- "timing_seconds": 0.0071,
21+ "timing_seconds": 0.0092,
2122 "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
2223 "more_accurate_than": []
2324 },
@@ -28,7 +29,7 @@
2829 "parameters": {
2930 "n_modes": 8
3031 },
31- "timing_seconds": 0.0171,
32+ "timing_seconds": 0.0214,
3233 "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
3334 "more_accurate_than": [
3435 "modal-n4"
@@ -41,7 +42,7 @@
4142 "parameters": {
4243 "n_modes": 16
4344 },
44- "timing_seconds": 0.0647,
45+ "timing_seconds": 0.0464,
4546 "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
4647 "more_accurate_than": [
4748 "modal-n8"
@@ -54,7 +55,7 @@
5455 "parameters": {
5556 "n_modes": 32
5657 },
57- "timing_seconds": 0.0676,
58+ "timing_seconds": 0.091,
5859 "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
5960 "more_accurate_than": [
6061 "modal-n16"
@@ -67,7 +68,7 @@
6768 "parameters": {
6869 "n_modes": 64
6970 },
70- "timing_seconds": 0.1221,
71+ "timing_seconds": 0.1391,
7172 "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
7273 "more_accurate_than": [
7374 "modal-n32"
@@ -80,7 +81,7 @@
8081 "parameters": {
8182 "n_modes": 256
8283 },
83- "timing_seconds": 0.5586,
84+ "timing_seconds": 0.636,
8485 "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
8586 "more_accurate_than": [
8687 "modal-n64"
@@ -93,8 +94,12 @@
9394 "parameters": {
9495 "n_modes": 1024
9596 },
96- "timing_seconds": 2.3347,
97+ "timing_seconds": 2.8862,
9798 "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
99+ "fields": {
100+ "path": "modal-series/modal-n1024.h5",
101+ "format": "hdf5"
102+ },
98103 "more_accurate_than": [
99104 "modal-n256"
100105 ]
solutions/string-pluck-v1/modal-series/modal-n1024.h5added+0−0View file
Binary file not shown.
solvers/fd/fd_string.pymodified+34−6View file
@@ -31,11 +31,15 @@ from scipy.linalg import solve_banded
3131
3232 sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
3333 from common import hardware_string, initial_shape, load_challenge, write_wav # noqa: E402
34+from fields import field_grid, write_fields # noqa: E402
3435
3536 LAM = 0.9
3637
3738
38-def solve(p: dict, fs: int, num_samples: int, m: int, lam: float = LAM):
39+def solve(p: dict, fs: int, num_samples: int, m: int, lam: float = LAM, field_x=None, field_t=None):
40+ """Returns (pickup samples, info, fields) where fields is u on (field_t, field_x) or None.
41+
42+ Field frames are taken at the step nearest each requested time and linearly interpolated in x."""
3943 dt = 1.0 / (fs * m)
4044 n_int = int(np.floor(p["L"] * lam / (p["c"] * dt))) # intervals; floor keeps dx >= c dt / lam
4145 dx = p["L"] / n_int
@@ -64,6 +68,16 @@ def solve(p: dict, fs: int, num_samples: int, m: int, lam: float = LAM):
6468 out = np.zeros(num_samples)
6569 total_steps = num_samples * m
6670 u_next = np.zeros_like(u_cur)
71+ fields = None
72+ frame_steps = {}
73+ if field_x is not None:
74+ fields = np.zeros((len(field_t), len(field_x)))
75+ for j, tj in enumerate(field_t):
76+ frame_steps.setdefault(int(round(tj / dt)), []).append(j)
77+ for j in frame_steps.get(0, []):
78+ fields[j] = np.interp(field_x, x, u_prev)
79+ for j in frame_steps.get(1, []):
80+ fields[j] = np.interp(field_x, x, u_cur)
6781 for n in range(1, total_steps + 1):
6882 rhs = 2.0 * u_cur + lam2 * D(u_cur) - (1.0 - s) * u_prev - beta * D(u_prev)
6983 u_next[1:-1] = solve_banded((1, 1), ab, rhs[1:-1], overwrite_b=True, check_finite=False)
@@ -72,24 +86,32 @@ def solve(p: dict, fs: int, num_samples: int, m: int, lam: float = LAM):
7286 if k < num_samples:
7387 v = (u_next - u_prev) / (2.0 * dt)
7488 out[k] = (1.0 - w1) * v[i0] + w1 * v[i0 + 1]
89+ for j in frame_steps.get(n + 1, []):
90+ fields[j] = np.interp(field_x, x, u_next)
7591 u_prev, u_cur, u_next = u_cur, u_next, u_prev
76- return out, {"m": m, "dt": dt, "dx": dx, "n_points": n_int + 1, "courant": p["c"] * dt / dx}
92+ return out, {"m": m, "dt": dt, "dx": dx, "n_points": n_int + 1, "courant": p["c"] * dt / dx}, fields
7793
7894
7995 def main():
8096 challenge = load_challenge(Path(sys.argv[1]))
8197 out_dir = Path(sys.argv[2])
82- ms = [int(a) for a in sys.argv[3:]] or [1, 2, 4, 8, 16]
98+ args = sys.argv[3:]
99+ fields_for = set()
100+ if "--fields" in args:
101+ i = args.index("--fields")
102+ fields_for = {int(v) for v in args[i + 1].split(",")}
103+ args = args[:i] + args[i + 2:]
104+ ms = [int(a) for a in args] or [1, 2, 4, 8, 16]
83105 p = challenge["parameters"]
84106 out = challenge["output"]
85107 meta = []
86108 for m in ms:
87109 t0 = time.perf_counter()
88- sig, info = solve(p, out["sample_rate_hz"], out["num_samples"], m)
110+ sig, info, _ = solve(p, out["sample_rate_hz"], out["num_samples"], m)
89111 dt = time.perf_counter() - t0
90112 sid = f"fd-m{m}"
91113 write_wav(out_dir / f"{sid}.wav", sig, challenge)
92- meta.append({
114+ entry = {
93115 "solution_id": sid,
94116 "output": f"{out_dir.name}/{sid}.wav",
95117 "method": "finite differences, second order, implicit damping",
@@ -97,7 +119,13 @@ def main():
97119 "timing_seconds": round(dt, 3),
98120 "hardware": hardware_string(),
99121 "peak_si": float(np.max(np.abs(sig))),
100- })
122+ }
123+ if m in fields_for:
124+ x, tt = field_grid(challenge)
125+ _, _, fld = solve(p, out["sample_rate_hz"], out["num_samples"], m, field_x=x, field_t=tt)
126+ write_fields(out_dir / f"{sid}.h5", x, tt, fld, challenge, sid)
127+ entry["fields"] = {"path": f"{out_dir.name}/{sid}.h5", "format": "hdf5"}
128+ meta.append(entry)
101129 print(f"m={m} points={info['n_points']} peak={np.max(np.abs(sig)):.4f} m/s t={dt:.2f}s", file=sys.stderr)
102130 print(json.dumps(meta, indent=1))
103131
solvers/fields.pyadded+28−0View file
@@ -0,0 +1,28 @@
1+"""Writing the optional field export (HDF5) described in the challenge README."""
2+from __future__ import annotations
3+
4+from pathlib import Path
5+
6+import h5py
7+import numpy as np
8+
9+
10+def field_grid(challenge: dict):
11+ """The recommended export grid for a challenge, from challenge.json."""
12+ fg = challenge["fields"]
13+ p = challenge["parameters"]
14+ x = np.linspace(0.0, p["L"], int(fg["nx"]))
15+ t = np.arange(int(fg["nt"])) / float(fg["frame_rate_hz"])
16+ return x, t
17+
18+
19+def write_fields(path: Path, x: np.ndarray, t: np.ndarray, u: np.ndarray, challenge: dict, solution_id: str) -> None:
20+ path.parent.mkdir(parents=True, exist_ok=True)
21+ with h5py.File(path, "w") as f:
22+ f.attrs["challenge_id"] = challenge["challenge_id"]
23+ f.attrs["solution_id"] = solution_id
24+ f.attrs["quantity"] = "transverse displacement"
25+ f.attrs["units"] = "m"
26+ f.create_dataset("x", data=np.asarray(x, dtype=np.float64))
27+ f.create_dataset("t", data=np.asarray(t, dtype=np.float64))
28+ f.create_dataset("u", data=np.asarray(u, dtype=np.float32), compression="gzip", compression_opts=4, chunks=(min(64, len(t)), len(x)))
solvers/modal/modal_string.pymodified+28−3View file
@@ -16,6 +16,7 @@ import numpy as np
1616
1717 sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
1818 from common import hardware_string, load_challenge, write_wav # noqa: E402
19+from fields import field_grid, write_fields # noqa: E402
1920
2021
2122 def modal_coefficients(p: dict, n_modes: int):
@@ -43,10 +44,29 @@ def pickup_velocity(p: dict, n_modes: int, t: np.ndarray) -> np.ndarray:
4344 return s
4445
4546
47+def displacement_field(p: dict, n_modes: int, x: np.ndarray, t: np.ndarray) -> np.ndarray:
48+ """u(x_i, t_j) as an array of shape (nt, nx)."""
49+ k, A, alpha, omega = modal_coefficients(p, n_modes)
50+ u = np.zeros((len(t), len(x)))
51+ block = 64
52+ for i in range(0, n_modes, block):
53+ sl = slice(i, i + block)
54+ e = np.exp(-np.outer(t, alpha[sl]))
55+ a = A[sl] * e * (np.cos(np.outer(t, omega[sl])) + (alpha[sl] / omega[sl]) * np.sin(np.outer(t, omega[sl])))
56+ u += a @ np.sin(np.outer(k[sl], x))
57+ return u
58+
59+
4660 def main():
4761 challenge = load_challenge(Path(sys.argv[1]))
4862 out_dir = Path(sys.argv[2])
49- orders = [int(a) for a in sys.argv[3:]] or [4, 8, 16, 32, 64, 256, 1024]
63+ args = sys.argv[3:]
64+ fields_for = set()
65+ if "--fields" in args:
66+ i = args.index("--fields")
67+ fields_for = {int(v) for v in args[i + 1].split(",")}
68+ args = args[:i] + args[i + 2:]
69+ orders = [int(a) for a in args] or [4, 8, 16, 32, 64, 256, 1024]
5070 p = challenge["parameters"]
5171 out = challenge["output"]
5272 fs = out["sample_rate_hz"]
@@ -58,7 +78,7 @@ def main():
5878 s = pickup_velocity(p, n, t)
5979 dt = time.perf_counter() - t0
6080 write_wav(out_dir / f"modal-n{n}.wav", s, challenge)
61- meta.append({
81+ entry = {
6282 "solution_id": f"modal-n{n}",
6383 "output": f"{out_dir.name}/modal-n{n}.wav",
6484 "method": "modal series, truncated",
@@ -66,7 +86,12 @@ def main():
6686 "timing_seconds": round(dt, 4),
6787 "hardware": hardware_string(),
6888 "peak_si": float(np.max(np.abs(s))),
69- })
89+ }
90+ if n in fields_for:
91+ x, tt = field_grid(challenge)
92+ write_fields(out_dir / f"modal-n{n}.h5", x, tt, displacement_field(p, n, x, tt), challenge, f"modal-n{n}")
93+ entry["fields"] = {"path": f"{out_dir.name}/modal-n{n}.h5", "format": "hdf5"}
94+ meta.append(entry)
7095 print(f"N={n:5d} peak={np.max(np.abs(s)):.4f} m/s t={dt:.3f}s", file=sys.stderr)
7196 print(json.dumps(meta, indent=1))
7297
web/build.pyadded+39−0View file
@@ -0,0 +1,39 @@
1+"""Assemble this repository's static site: a manifest of the submissions, the solution files, and the viewer page."""
2+from __future__ import annotations
3+
4+import argparse
5+import json
6+import shutil
7+from pathlib import Path
8+
9+ROOT = Path(__file__).resolve().parents[1]
10+
11+
12+def main():
13+ ap = argparse.ArgumentParser()
14+ ap.add_argument("--out", type=Path, default=ROOT / "site")
15+ args = ap.parse_args()
16+ out: Path = args.out
17+ if out.exists():
18+ shutil.rmtree(out)
19+ out.mkdir(parents=True)
20+ manifest = {"challenges": {}}
21+ for p in sorted((ROOT / "solutions").glob("*/*.json")):
22+ sub = json.loads(p.read_text())
23+ cid = p.parent.name
24+ entry = manifest["challenges"].setdefault(cid, {"submissions": []})
25+ rel_dir = p.parent.relative_to(ROOT)
26+ for sol in sub["solutions"]:
27+ sol["output"] = f"{rel_dir.as_posix()}/{sol['output']}"
28+ if "fields" in sol and not sol["fields"]["path"].startswith("http"):
29+ sol["fields"]["path"] = f"{rel_dir.as_posix()}/{sol['fields']['path']}"
30+ entry["submissions"].append(sub)
31+ shutil.copytree(ROOT / "solutions", out / "solutions")
32+ (out / "manifest.json").write_text(json.dumps(manifest, indent=1))
33+ shutil.copyfile(ROOT / "web" / "index.html", out / "index.html")
34+ shutil.copyfile(ROOT / "web" / "viewer.js", out / "viewer.js")
35+ print(f"site written to {out}")
36+
37+
38+if __name__ == "__main__":
39+ main()
web/index.htmladded+45−0View file
@@ -0,0 +1,45 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<title>String solutions</title>
7+<style>
8+:root { --fg: #1b1b1b; --bg: #fff; --muted: #666; --line: #ddd; --accent: #2a6db0; --string: #1b1b1b; --rest: #bbb; }
9+@media (prefers-color-scheme: dark) { :root { --fg: #e6e6e6; --bg: #151515; --muted: #999; --line: #333; --accent: #7ab3e8; --string: #e6e6e6; --rest: #444; } }
10+body { font-family: system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 16px; line-height: 1.45; }
11+main { max-width: 1100px; margin: 0 auto; }
12+a { color: var(--accent); }
13+table { border-collapse: collapse; width: 100%; font-size: 0.92em; }
14+th, td { border-bottom: 1px solid var(--line); padding: 6px 8px; text-align: left; vertical-align: top; }
15+td.num { text-align: right; font-variant-numeric: tabular-nums; }
16+.muted { color: var(--muted); }
17+.wrap { overflow-x: auto; }
18+audio { width: 180px; height: 28px; }
19+canvas { width: 100%; height: 260px; display: block; border: 1px solid var(--line); border-radius: 4px; }
20+.controls { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin: 8px 0; }
21+.controls label { display: flex; gap: 6px; align-items: center; }
22+input[type=range] { width: 200px; }
23+button { font: inherit; padding: 4px 10px; }
24+</style>
25+</head>
26+<body><main>
27+<h1>Solutions to the string challenges</h1>
28+<p>This is the site of the solution repository <a href="https://vault1.magland.org/challenges/string-solutions">challenges/string-solutions</a>. The comparison of all registered solutions against the reference is on the <a href="https://vault1.magland.org/challenges/string/site/">challenge repository's results site</a>; this page lists this repository's own submissions and animates the exported displacement fields.</p>
29+
30+<h2>Displacement animation</h2>
31+<div class="controls">
32+ <label>Solution <select id="fieldSelect"></select></label>
33+ <button id="playBtn">Play</button>
34+ <label>Speed <input id="speed" type="range" min="-4" max="0" step="0.1" value="-2.5"> <span id="speedLabel"></span></label>
35+ <label>Time <input id="timeSlider" type="range" min="0" max="1" step="1" value="0"> <span id="timeLabel" class="muted"></span></label>
36+</div>
37+<canvas id="canvas"></canvas>
38+<p id="fieldStatus" class="muted">Loading…</p>
39+
40+<h2>Submissions</h2>
41+<div id="submissions"></div>
42+</main>
43+<script type="module" src="viewer.js"></script>
44+</body>
45+</html>
web/viewer.jsadded+155−0View file
@@ -0,0 +1,155 @@
1+// Lists this repository's submissions and animates the exported HDF5 displacement fields (read with h5wasm).
2+import h5wasm from "https://cdn.jsdelivr.net/npm/h5wasm@0.7/dist/esm/hdf5_hl.js";
3+
4+const manifest = await (await fetch("manifest.json")).json();
5+
6+// ---------------------------------------------------------------- submissions table
7+const subsDiv = document.getElementById("submissions");
8+const fieldSolutions = [];
9+for (const [cid, ch] of Object.entries(manifest.challenges)) {
10+ const h3 = document.createElement("h3");
11+ h3.textContent = cid;
12+ subsDiv.appendChild(h3);
13+ for (const sub of ch.submissions) {
14+ const p = document.createElement("p");
15+ p.innerHTML = `<b>${esc(sub.submission_id)}</b> by ${esc(sub.submitter.name)}${sub.date ? " (" + esc(sub.date) + ")" : ""}. ${esc(sub.description || "")}` +
16+ (sub.code_url ? ` <a href="${esc(sub.code_url)}">code</a>` : "");
17+ subsDiv.appendChild(p);
18+ const rows = sub.solutions.map((s) => {
19+ if (s.fields) fieldSolutions.push({ label: `${sub.submission_id}/${s.solution_id}`, path: s.fields.path });
20+ return `<tr><td>${esc(s.solution_id)}</td><td>${esc(s.method || "")}<br><span class="muted"><code>${esc(JSON.stringify(s.parameters || {}))}</code></span></td>` +
21+ `<td class="num">${s.timing_seconds ?? ""}</td><td class="muted">${esc((s.more_accurate_than || []).join(", "))}</td>` +
22+ `<td><audio controls preload="none" src="${esc(s.output)}"></audio><br><a href="${esc(s.output)}">wav</a>${s.fields ? ` <a href="${esc(s.fields.path)}">fields</a>` : ""}</td></tr>`;
23+ }).join("");
24+ const wrap = document.createElement("div");
25+ wrap.className = "wrap";
26+ wrap.innerHTML = `<table><tr><th>Solution</th><th>Method</th><th>Time (s)</th><th>Claimed more accurate than</th><th>Output</th></tr>${rows}</table>`;
27+ subsDiv.appendChild(wrap);
28+ }
29+}
30+
31+// ---------------------------------------------------------------- field viewer
32+const select = document.getElementById("fieldSelect");
33+const status = document.getElementById("fieldStatus");
34+const canvas = document.getElementById("canvas");
35+const ctx = canvas.getContext("2d");
36+const playBtn = document.getElementById("playBtn");
37+const speed = document.getElementById("speed");
38+const speedLabel = document.getElementById("speedLabel");
39+const timeSlider = document.getElementById("timeSlider");
40+const timeLabel = document.getElementById("timeLabel");
41+
42+for (const f of fieldSolutions) {
43+ const o = document.createElement("option");
44+ o.value = f.path;
45+ o.textContent = f.label;
46+ select.appendChild(o);
47+}
48+
49+let field = null; // {x, t, u (Float32Array nt*nx), nx, nt, umax}
50+let frame = 0;
51+let playing = false;
52+let lastTs = null;
53+let simTime = 0;
54+
55+async function loadField(path) {
56+ status.textContent = `Loading ${path}…`;
57+ playing = false;
58+ playBtn.textContent = "Play";
59+ try {
60+ const { FS } = await h5wasm.ready;
61+ const buf = new Uint8Array(await (await fetch(path)).arrayBuffer());
62+ const name = "current.h5";
63+ try { FS.unlink(name); } catch (_) {}
64+ FS.writeFile(name, buf);
65+ const file = new h5wasm.File(name, "r");
66+ const x = Float64Array.from(file.get("x").value);
67+ const t = Float64Array.from(file.get("t").value);
68+ const u = Float32Array.from(file.get("u").value);
69+ file.close();
70+ let umax = 0;
71+ for (let i = 0; i < u.length; i++) umax = Math.max(umax, Math.abs(u[i]));
72+ field = { x, t, u, nx: x.length, nt: t.length, umax };
73+ timeSlider.max = String(field.nt - 1);
74+ frame = 0;
75+ simTime = 0;
76+ status.textContent = `${path}: ${field.nx} points × ${field.nt} frames, max |u| = ${(umax * 1e3).toFixed(3)} mm`;
77+ draw();
78+ } catch (e) {
79+ status.textContent = `Could not load ${path}: ${e}`;
80+ }
81+}
82+
83+function draw() {
84+ const dpr = window.devicePixelRatio || 1;
85+ const w = canvas.clientWidth, h = canvas.clientHeight;
86+ if (canvas.width !== Math.round(w * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); }
87+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
88+ ctx.clearRect(0, 0, w, h);
89+ const css = getComputedStyle(document.documentElement);
90+ ctx.strokeStyle = css.getPropertyValue("--rest");
91+ ctx.beginPath(); ctx.moveTo(20, h / 2); ctx.lineTo(w - 20, h / 2); ctx.stroke();
92+ if (!field) return;
93+ const { x, u, nx, umax } = field;
94+ const L = x[nx - 1];
95+ const scale = umax > 0 ? (0.42 * h) / umax : 0;
96+ ctx.strokeStyle = css.getPropertyValue("--string");
97+ ctx.lineWidth = 2;
98+ ctx.beginPath();
99+ for (let i = 0; i < nx; i++) {
100+ const px = 20 + (x[i] / L) * (w - 40);
101+ const py = h / 2 - u[frame * nx + i] * scale;
102+ if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
103+ }
104+ ctx.stroke();
105+ // pickup marker at x_m = 0.54 m
106+ const xm = 20 + (0.54 / L) * (w - 40);
107+ ctx.strokeStyle = css.getPropertyValue("--accent");
108+ ctx.setLineDash([4, 4]);
109+ ctx.beginPath(); ctx.moveTo(xm, 10); ctx.lineTo(xm, h - 10); ctx.stroke();
110+ ctx.setLineDash([]);
111+ ctx.fillStyle = css.getPropertyValue("--muted");
112+ ctx.font = "12px system-ui, sans-serif";
113+ ctx.fillText(`t = ${(field.t[frame] * 1e3).toFixed(1)} ms`, 24, 16);
114+ ctx.fillText("pickup", xm + 4, h - 14);
115+ timeSlider.value = String(frame);
116+ timeLabel.textContent = `${(field.t[frame] * 1e3).toFixed(1)} ms`;
117+}
118+
119+function tick(ts) {
120+ if (!playing || !field) return;
121+ if (lastTs !== null) {
122+ const factor = Math.pow(10, parseFloat(speed.value));
123+ simTime += ((ts - lastTs) / 1000) * factor;
124+ const tEnd = field.t[field.nt - 1];
125+ if (simTime > tEnd) simTime = 0;
126+ const dt = field.t[1] - field.t[0];
127+ frame = Math.min(field.nt - 1, Math.round(simTime / dt));
128+ draw();
129+ }
130+ lastTs = ts;
131+ requestAnimationFrame(tick);
132+}
133+
134+playBtn.addEventListener("click", () => {
135+ playing = !playing;
136+ playBtn.textContent = playing ? "Pause" : "Play";
137+ lastTs = null;
138+ if (playing) requestAnimationFrame(tick);
139+});
140+timeSlider.addEventListener("input", () => {
141+ if (!field) return;
142+ frame = parseInt(timeSlider.value, 10);
143+ simTime = field.t[frame];
144+ draw();
145+});
146+function updateSpeedLabel() { speedLabel.textContent = `${Math.pow(10, parseFloat(speed.value)).toPrecision(2)} × real time`; }
147+speed.addEventListener("input", updateSpeedLabel);
148+updateSpeedLabel();
149+select.addEventListener("change", () => loadField(select.value));
150+window.addEventListener("resize", draw);
151+
152+if (fieldSolutions.length) loadField(fieldSolutions[0].path);
153+else status.textContent = "No solution in this repository exports a displacement field.";
154+
155+function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); }