Add modal-series and fd-second-order submissions for string-pluck-v1, with results workflow
20 changed files+493−0
.gitignoreadded+4−0View file
@@ -0,0 +1,4 @@
1+__pycache__/
2+site/
3+.challenge-cache/
4+challenge-repo/
.mochi/workflows/results.ymladded+36−0View file
@@ -0,0 +1,36 @@
1+name: Results
2+on:
3+ push:
4+ workflow_dispatch:
5+concurrency:
6+ group: results
7+ cancel-in-progress: true
8+jobs:
9+ results:
10+ runs-on: ubuntu-latest
11+ steps:
12+ - uses: actions/checkout@v4
13+ - name: Check out the challenge repository
14+ uses: actions/checkout@v4
15+ with:
16+ repository: challenges/string
17+ path: challenge-repo
18+ - uses: actions/setup-python@v5
19+ with:
20+ python-version: '3.12'
21+ - run: pip install ./challenge-repo
22+ - name: Validate submissions
23+ run: challenge-tools validate --challenges challenge-repo/challenges --solutions solutions
24+ - name: Build results site
25+ run: >
26+ challenge-tools build --challenges challenge-repo/challenges --solutions solutions --out site
27+ --challenge-repo-url https://vault1.magland.org/challenges/string
28+ --solutions-repo-url https://vault1.magland.org/challenges/string-solutions
29+ - if: github.ref == 'refs/heads/main'
30+ uses: actions/configure-pages@v4
31+ - if: github.ref == 'refs/heads/main'
32+ uses: actions/upload-pages-artifact@v3
33+ with:
34+ path: site
35+ - if: github.ref == 'refs/heads/main'
36+ uses: actions/deploy-pages@v4
README.mdadded+28−0View file
@@ -0,0 +1,28 @@
1+# Solutions to the string challenges
2+
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`.
4+
5+## Layout
6+
7+```
8+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
11+```
12+
13+## Submitting
14+
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.
20+
21+Timing is self-reported wall-clock time for producing the output signal, excluding file I/O, on the hardware named in the `hardware` field.
22+
23+## Validating locally
24+
25+```
26+pip install git+https://vault1.magland.org/challenges/string
27+challenge-tools validate --challenges <path-to-string-repo>/challenges --solutions solutions
28+```
solutions/string-pluck-v1/fd-second-order.jsonadded+97−0View file
@@ -0,0 +1,97 @@
1+{
2+ "schema_version": 1,
3+ "submission_id": "fd-second-order",
4+ "challenge_id": "string-pluck-v1",
5+ "submitter": {
6+ "name": "Jeremy Magland",
7+ "email": "jmagland@flatironinstitute.org"
8+ },
9+ "date": "2026-09-17",
10+ "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.",
11+ "code_url": "https://vault1.magland.org/challenges/string-solutions/blob/main/solvers/fd/fd_string.py",
12+ "solutions": [
13+ {
14+ "solution_id": "fd-m1",
15+ "output": "fd-second-order/fd-m1.wav",
16+ "method": "finite differences, second order, implicit damping",
17+ "parameters": {
18+ "oversampling": 1,
19+ "n_points": 197,
20+ "dx_m": 0.003316326530612245,
21+ "dt_s": 2.0833333333333333e-05,
22+ "courant": 0.898333
23+ },
24+ "timing_seconds": 2.033,
25+ "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
26+ "more_accurate_than": []
27+ },
28+ {
29+ "solution_id": "fd-m2",
30+ "output": "fd-second-order/fd-m2.wav",
31+ "method": "finite differences, second order, implicit damping",
32+ "parameters": {
33+ "oversampling": 2,
34+ "n_points": 393,
35+ "dx_m": 0.0016581632653061226,
36+ "dt_s": 1.0416666666666666e-05,
37+ "courant": 0.898333
38+ },
39+ "timing_seconds": 4.565,
40+ "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
41+ "more_accurate_than": [
42+ "fd-m1"
43+ ]
44+ },
45+ {
46+ "solution_id": "fd-m4",
47+ "output": "fd-second-order/fd-m4.wav",
48+ "method": "finite differences, second order, implicit damping",
49+ "parameters": {
50+ "oversampling": 4,
51+ "n_points": 786,
52+ "dx_m": 0.0008280254777070064,
53+ "dt_s": 5.208333333333333e-06,
54+ "courant": 0.899479
55+ },
56+ "timing_seconds": 11.802,
57+ "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
58+ "more_accurate_than": [
59+ "fd-m2"
60+ ]
61+ },
62+ {
63+ "solution_id": "fd-m8",
64+ "output": "fd-second-order/fd-m8.wav",
65+ "method": "finite differences, second order, implicit damping",
66+ "parameters": {
67+ "oversampling": 8,
68+ "n_points": 1571,
69+ "dx_m": 0.0004140127388535032,
70+ "dt_s": 2.6041666666666666e-06,
71+ "courant": 0.899479
72+ },
73+ "timing_seconds": 31.794,
74+ "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
75+ "more_accurate_than": [
76+ "fd-m4"
77+ ]
78+ },
79+ {
80+ "solution_id": "fd-m16",
81+ "output": "fd-second-order/fd-m16.wav",
82+ "method": "finite differences, second order, implicit damping",
83+ "parameters": {
84+ "oversampling": 16,
85+ "n_points": 3142,
86+ "dx_m": 0.000206940464820121,
87+ "dt_s": 1.3020833333333333e-06,
88+ "courant": 0.899766
89+ },
90+ "timing_seconds": 101.051,
91+ "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
92+ "more_accurate_than": [
93+ "fd-m8"
94+ ]
95+ }
96+ ]
97+}
solutions/string-pluck-v1/fd-second-order/fd-m1.wavadded+0−0View file
Binary file not shown.
solutions/string-pluck-v1/fd-second-order/fd-m16.wavadded+0−0View file
Binary file not shown.
solutions/string-pluck-v1/fd-second-order/fd-m2.wavadded+0−0View file
Binary file not shown.
solutions/string-pluck-v1/fd-second-order/fd-m4.wavadded+0−0View file
Binary file not shown.
solutions/string-pluck-v1/fd-second-order/fd-m8.wavadded+0−0View file
Binary file not shown.
solutions/string-pluck-v1/modal-series.jsonadded+103−0View file
@@ -0,0 +1,103 @@
1+{
2+ "schema_version": 1,
3+ "submission_id": "modal-series",
4+ "challenge_id": "string-pluck-v1",
5+ "submitter": {
6+ "name": "Jeremy Magland",
7+ "email": "jmagland@flatironinstitute.org"
8+ },
9+ "date": "2026-09-17",
10+ "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.",
11+ "code_url": "https://vault1.magland.org/challenges/string-solutions/blob/main/solvers/modal/modal_string.py",
12+ "solutions": [
13+ {
14+ "solution_id": "modal-n4",
15+ "output": "modal-series/modal-n4.wav",
16+ "method": "modal series, truncated",
17+ "parameters": {
18+ "n_modes": 4
19+ },
20+ "timing_seconds": 0.0071,
21+ "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
22+ "more_accurate_than": []
23+ },
24+ {
25+ "solution_id": "modal-n8",
26+ "output": "modal-series/modal-n8.wav",
27+ "method": "modal series, truncated",
28+ "parameters": {
29+ "n_modes": 8
30+ },
31+ "timing_seconds": 0.0171,
32+ "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
33+ "more_accurate_than": [
34+ "modal-n4"
35+ ]
36+ },
37+ {
38+ "solution_id": "modal-n16",
39+ "output": "modal-series/modal-n16.wav",
40+ "method": "modal series, truncated",
41+ "parameters": {
42+ "n_modes": 16
43+ },
44+ "timing_seconds": 0.0647,
45+ "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
46+ "more_accurate_than": [
47+ "modal-n8"
48+ ]
49+ },
50+ {
51+ "solution_id": "modal-n32",
52+ "output": "modal-series/modal-n32.wav",
53+ "method": "modal series, truncated",
54+ "parameters": {
55+ "n_modes": 32
56+ },
57+ "timing_seconds": 0.0676,
58+ "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
59+ "more_accurate_than": [
60+ "modal-n16"
61+ ]
62+ },
63+ {
64+ "solution_id": "modal-n64",
65+ "output": "modal-series/modal-n64.wav",
66+ "method": "modal series, truncated",
67+ "parameters": {
68+ "n_modes": 64
69+ },
70+ "timing_seconds": 0.1221,
71+ "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
72+ "more_accurate_than": [
73+ "modal-n32"
74+ ]
75+ },
76+ {
77+ "solution_id": "modal-n256",
78+ "output": "modal-series/modal-n256.wav",
79+ "method": "modal series, truncated",
80+ "parameters": {
81+ "n_modes": 256
82+ },
83+ "timing_seconds": 0.5586,
84+ "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
85+ "more_accurate_than": [
86+ "modal-n64"
87+ ]
88+ },
89+ {
90+ "solution_id": "modal-n1024",
91+ "output": "modal-series/modal-n1024.wav",
92+ "method": "modal series, truncated",
93+ "parameters": {
94+ "n_modes": 1024
95+ },
96+ "timing_seconds": 2.3347,
97+ "hardware": "13th Gen Intel(R) Core(TM) i7-1355U, single thread, Python 3.13.5 + numpy 2.4.4",
98+ "more_accurate_than": [
99+ "modal-n256"
100+ ]
101+ }
102+ ]
103+}
solutions/string-pluck-v1/modal-series/modal-n1024.wavadded+0−0View file
Binary file not shown.
solutions/string-pluck-v1/modal-series/modal-n16.wavadded+0−0View file
Binary file not shown.
solutions/string-pluck-v1/modal-series/modal-n256.wavadded+0−0View file
Binary file not shown.
solutions/string-pluck-v1/modal-series/modal-n32.wavadded+0−0View file
Binary file not shown.
solutions/string-pluck-v1/modal-series/modal-n4.wavadded+0−0View file
Binary file not shown.
solutions/string-pluck-v1/modal-series/modal-n64.wavadded+0−0View file
Binary file not shown.
solutions/string-pluck-v1/modal-series/modal-n8.wavadded+0−0View file
Binary file not shown.
solvers/common.pyadded+44−0View file
@@ -0,0 +1,44 @@
1+"""Shared helpers for the example solvers: load the challenge, write wavs, write submission JSON."""
2+from __future__ import annotations
3+
4+import json
5+import platform
6+from pathlib import Path
7+
8+import numpy as np
9+from scipy.io import wavfile
10+
11+
12+def load_challenge(path: Path) -> dict:
13+ with open(path) as f:
14+ return json.load(f)
15+
16+
17+def write_wav(path: Path, samples_si: np.ndarray, challenge: dict) -> None:
18+ """Write pointwise samples of the SI signal, divided by signal_scale, as float32."""
19+ out = challenge["output"]
20+ scaled = np.asarray(samples_si, dtype=np.float64) / float(out["signal_scale"])
21+ path.parent.mkdir(parents=True, exist_ok=True)
22+ wavfile.write(path, int(out["sample_rate_hz"]), scaled.astype(np.float32))
23+
24+
25+def hardware_string() -> str:
26+ cpu = platform.processor() or platform.machine()
27+ try:
28+ with open("/proc/cpuinfo") as f:
29+ for line in f:
30+ if line.startswith("model name"):
31+ cpu = line.split(":", 1)[1].strip()
32+ break
33+ except OSError:
34+ pass
35+ return f"{cpu}, single thread, Python {platform.python_version()} + numpy {np.__version__}"
36+
37+
38+def initial_shape(x: np.ndarray, p: dict) -> np.ndarray:
39+ """Raised-cosine pluck u_0(x) from the challenge spec."""
40+ d = x - p["x_p"]
41+ u = np.zeros_like(x)
42+ m = np.abs(d) <= p["w"]
43+ u[m] = 0.5 * p["h"] * (1.0 + np.cos(np.pi * d[m] / p["w"]))
44+ return u
solvers/fd/fd_string.pyadded+106−0View file
@@ -0,0 +1,106 @@
1+"""Finite-difference solution of string-pluck-v1, second order in space and time.
2+
3+With D the standard three-point second difference (zero at the fixed ends) and u^n the grid
4+values at step n, the scheme is
5+
6+ (u^{n+1} - 2u^n + u^{n-1})/dt^2 = c^2 D u^n / dx^2
7+ - sigma0 (u^{n+1} - u^{n-1}) / dt
8+ + kappa D (u^{n+1} - u^{n-1}) / (2 dt dx^2).
9+
10+The Kelvin-Voigt term is centered in time, so the scheme is implicit in the damping and a
11+tridiagonal system is solved at every step. (An earlier explicit variant with a backward
12+difference for that term is only first order in time; its error cancelled against the
13+second-order dispersion error near m = 8 and the sequence was not monotone.) Stability
14+requires the Courant number c dt/dx <= 1.
15+
16+The time step is dt = 1/(fs*m) for an integer oversampling factor m, and the grid spacing is
17+dx = c dt / lam with lam = 0.9, so the scheme is deliberately dispersive. The pickup velocity is
18+(u^{n+1} - u^{n-1})/(2 dt) linearly interpolated to x_m, taken every m-th step.
19+
20+Usage: python fd_string.py <challenge.json> <out-dir> [m ...]
21+"""
22+from __future__ import annotations
23+
24+import json
25+import sys
26+import time
27+from pathlib import Path
28+
29+import numpy as np
30+from scipy.linalg import solve_banded
31+
32+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
33+from common import hardware_string, initial_shape, load_challenge, write_wav # noqa: E402
34+
35+LAM = 0.9
36+
37+
38+def solve(p: dict, fs: int, num_samples: int, m: int, lam: float = LAM):
39+ dt = 1.0 / (fs * m)
40+ n_int = int(np.floor(p["L"] * lam / (p["c"] * dt))) # intervals; floor keeps dx >= c dt / lam
41+ dx = p["L"] / n_int
42+ x = np.linspace(0.0, p["L"], n_int + 1)
43+ lam2 = (p["c"] * dt / dx) ** 2
44+ s = p["sigma0"] * dt
45+ beta = p["kappa"] * dt / (2.0 * dx**2)
46+
47+ def D(u):
48+ out = np.zeros_like(u)
49+ out[1:-1] = u[:-2] - 2.0 * u[1:-1] + u[2:]
50+ return out
51+
52+ # Left-hand operator on interior points: (1 + s) I - beta D, tridiagonal, in solve_banded form.
53+ n_in = n_int - 1
54+ ab = np.zeros((3, n_in))
55+ ab[0, 1:] = -beta
56+ ab[1, :] = (1.0 + s) + 2.0 * beta
57+ ab[2, :-1] = -beta
58+
59+ u_prev = initial_shape(x, p)
60+ u_cur = u_prev + 0.5 * dt**2 * (p["c"] ** 2) * D(u_prev) / dx**2 # zero initial velocity, second-order start
61+ xi = p["x_m"] / dx
62+ i0 = int(np.floor(xi))
63+ w1 = xi - i0
64+ out = np.zeros(num_samples)
65+ total_steps = num_samples * m
66+ u_next = np.zeros_like(u_cur)
67+ for n in range(1, total_steps + 1):
68+ rhs = 2.0 * u_cur + lam2 * D(u_cur) - (1.0 - s) * u_prev - beta * D(u_prev)
69+ u_next[1:-1] = solve_banded((1, 1), ab, rhs[1:-1], overwrite_b=True, check_finite=False)
70+ if n % m == 0:
71+ k = n // m
72+ if k < num_samples:
73+ v = (u_next - u_prev) / (2.0 * dt)
74+ out[k] = (1.0 - w1) * v[i0] + w1 * v[i0 + 1]
75+ 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}
77+
78+
79+def main():
80+ challenge = load_challenge(Path(sys.argv[1]))
81+ out_dir = Path(sys.argv[2])
82+ ms = [int(a) for a in sys.argv[3:]] or [1, 2, 4, 8, 16]
83+ p = challenge["parameters"]
84+ out = challenge["output"]
85+ meta = []
86+ for m in ms:
87+ t0 = time.perf_counter()
88+ sig, info = solve(p, out["sample_rate_hz"], out["num_samples"], m)
89+ dt = time.perf_counter() - t0
90+ sid = f"fd-m{m}"
91+ write_wav(out_dir / f"{sid}.wav", sig, challenge)
92+ meta.append({
93+ "solution_id": sid,
94+ "output": f"{out_dir.name}/{sid}.wav",
95+ "method": "finite differences, second order, implicit damping",
96+ "parameters": {"oversampling": m, "n_points": info["n_points"], "dx_m": info["dx"], "dt_s": info["dt"], "courant": round(info["courant"], 6)},
97+ "timing_seconds": round(dt, 3),
98+ "hardware": hardware_string(),
99+ "peak_si": float(np.max(np.abs(sig))),
100+ })
101+ print(f"m={m} points={info['n_points']} peak={np.max(np.abs(sig)):.4f} m/s t={dt:.2f}s", file=sys.stderr)
102+ print(json.dumps(meta, indent=1))
103+
104+
105+if __name__ == "__main__":
106+ main()
solvers/modal/modal_string.pyadded+75−0View file
@@ -0,0 +1,75 @@
1+"""Modal series solution of string-pluck-v1 (see the appendix of the challenge README).
2+
3+Usage: python modal_string.py <challenge.json> <out-dir> [N ...]
4+
5+Writes modal-n<N>.wav for each truncation order N and prints a JSON list of per-solution
6+metadata (timing, parameters) to stdout.
7+"""
8+from __future__ import annotations
9+
10+import json
11+import sys
12+import time
13+from pathlib import Path
14+
15+import numpy as np
16+
17+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
18+from common import hardware_string, load_challenge, write_wav # noqa: E402
19+
20+
21+def modal_coefficients(p: dict, n_modes: int):
22+ n = np.arange(1, n_modes + 1)
23+ k = n * np.pi / p["L"]
24+ q = np.pi / p["w"]
25+ A = (2.0 * p["h"] / p["L"]) * np.sin(k * p["x_p"]) * np.sin(k * p["w"]) * q**2 / (k * (q**2 - k**2))
26+ alpha = p["sigma0"] + 0.5 * p["kappa"] * k**2
27+ omega2 = (p["c"] * k) ** 2 - alpha**2
28+ if np.any(omega2 <= 0):
29+ raise ValueError("an overdamped mode is within the requested truncation; not handled")
30+ omega = np.sqrt(omega2)
31+ return k, A, alpha, omega
32+
33+
34+def pickup_velocity(p: dict, n_modes: int, t: np.ndarray) -> np.ndarray:
35+ k, A, alpha, omega = modal_coefficients(p, n_modes)
36+ coeff = -A * (p["c"] * k) ** 2 / omega * np.sin(k * p["x_m"]) # per-mode amplitude of a_n'(t) sin(k_n x_m)
37+ s = np.zeros_like(t)
38+ block = 64 # modes per block, to bound memory for large N
39+ for i in range(0, n_modes, block):
40+ sl = slice(i, i + block)
41+ e = np.exp(-np.outer(t, alpha[sl]))
42+ s += (e * np.sin(np.outer(t, omega[sl]))) @ coeff[sl]
43+ return s
44+
45+
46+def main():
47+ challenge = load_challenge(Path(sys.argv[1]))
48+ out_dir = Path(sys.argv[2])
49+ orders = [int(a) for a in sys.argv[3:]] or [4, 8, 16, 32, 64, 256, 1024]
50+ p = challenge["parameters"]
51+ out = challenge["output"]
52+ fs = out["sample_rate_hz"]
53+ t = np.arange(out["num_samples"]) / fs
54+ meta = []
55+ for n in orders:
56+ pickup_velocity(p, min(n, 8), t[:16]) # warm up
57+ t0 = time.perf_counter()
58+ s = pickup_velocity(p, n, t)
59+ dt = time.perf_counter() - t0
60+ write_wav(out_dir / f"modal-n{n}.wav", s, challenge)
61+ meta.append({
62+ "solution_id": f"modal-n{n}",
63+ "output": f"{out_dir.name}/modal-n{n}.wav",
64+ "method": "modal series, truncated",
65+ "parameters": {"n_modes": n},
66+ "timing_seconds": round(dt, 4),
67+ "hardware": hardware_string(),
68+ "peak_si": float(np.max(np.abs(s))),
69+ })
70+ print(f"N={n:5d} peak={np.max(np.abs(s)):.4f} m/s t={dt:.3f}s", file=sys.stderr)
71+ print(json.dumps(meta, indent=1))
72+
73+
74+if __name__ == "__main__":
75+ main()