From 5c0ea2541befd440827b6603e389cb3ae1c1ff58 Mon Sep 17 00:00:00 2001 From: MellowMango Date: Wed, 21 May 2025 20:15:57 -0700 Subject: [PATCH 1/2] Merge work branch changes --- .github/workflows/main.yml | 20 ++++++++ README.md | 42 +++++++++++++++- config/default.yaml | 21 ++++++++ data/seeds.txt | 0 notebooks/exploratory.ipynb | 1 + pyproject.toml | 22 +++++++++ src/fps/__init__.py | 7 +++ src/fps/metrics.py | 13 +++++ src/fps/parameters.py | 45 +++++++++++++++++ src/fps/server.py | 58 ++++++++++++++++++++++ src/fps/simulate.py | 97 +++++++++++++++++++++++++++++++++++++ src/fps/spiral.py | 12 +++++ src/fps/strata.py | 19 ++++++++ src/fps/visualise.py | 16 ++++++ src/tests/test_metrics.py | 7 +++ src/tests/test_simulate.py | 14 ++++++ src/tests/test_spiral.py | 6 +++ 17 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/main.yml create mode 100644 config/default.yaml create mode 100644 data/seeds.txt create mode 100644 notebooks/exploratory.ipynb create mode 100644 pyproject.toml create mode 100644 src/fps/__init__.py create mode 100644 src/fps/metrics.py create mode 100644 src/fps/parameters.py create mode 100644 src/fps/server.py create mode 100644 src/fps/simulate.py create mode 100644 src/fps/spiral.py create mode 100644 src/fps/strata.py create mode 100644 src/fps/visualise.py create mode 100644 src/tests/test_metrics.py create mode 100644 src/tests/test_simulate.py create mode 100644 src/tests/test_spiral.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..4375c15 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,20 @@ +# .github/workflows/auto-resolve.yml +name: Auto-rebase-prefer-PR +on: + pull_request_target: + types: [opened, synchronize, ready_for_review, reopened] +jobs: + rebase: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} # the PR branch + fetch-depth: 0 + - run: | + git config user.name "merge-bot" + git config user.email "bot@example.com" + git fetch origin ${{ github.base_ref }} + git merge -s ort -X theirs origin/${{ github.base_ref }} --commit -m "Auto-merge main into PR" + git push origin HEAD diff --git a/README.md b/README.md index 7d26606..a0bba07 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,40 @@ -# fps-simulation-validation -A minimal‑yet‑rigorous code scaffold for reproducing, stress‑testing, and visualising the Fractale Pulsante Spiralée (FPS) model described in the paper and roadmap. Designed for fast iteration, statistical reproducibility, and transparent peer review. +# FPS Simulation & Validation Toolkit (v0.1) + +A minimal-yet-rigorous code scaffold for reproducing, stress-testing, and visualising the Fractale Pulsante Spiralée (FPS) model. Designed for fast iteration, statistical reproducibility, and transparent peer review. + +## Quick start + +1. **Install dependencies** + + ```bash + pip install -e . + ``` + +2. **Run a baseline simulation** + + ```bash + python -m fps.simulate --config config/default.yaml + ``` + + Results are written to `data/logs/`. Adjust parameters in + `config/default.yaml` to experiment with different settings. + +3. **Launch the web server** (requires `uvicorn`) + + ```bash + uvicorn fps.server:app --reload + ``` + + Trigger a run via + + ```bash + curl -X POST http://127.0.0.1:8000/run + ``` + + View the coherence plot at + + ```bash + http://127.0.0.1:8000/plot/ + ``` + + Replace `` with the `run_name` specified in the config. diff --git a/config/default.yaml b/config/default.yaml new file mode 100644 index 0000000..ae92fba --- /dev/null +++ b/config/default.yaml @@ -0,0 +1,21 @@ +run_name: "baseline_N5" +seed: 42 +T: 20.0 +Δt: 0.01 +n_strata: 5 +strata_defaults: + A0: 1.0 + f0: 1.0 + φ0: 0.0 + γ0: 1.0 + α: 0.1 + β: 0.05 + λ: 0.01 +feedback: + G: "tanh" +noise: + type: "uniform" + scale: 0.1 +logging: + every_step: 10 + format: "csv" diff --git a/data/seeds.txt b/data/seeds.txt new file mode 100644 index 0000000..e69de29 diff --git a/notebooks/exploratory.ipynb b/notebooks/exploratory.ipynb new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/notebooks/exploratory.ipynb @@ -0,0 +1 @@ +{} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f165901 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=42"] +build-backend = "setuptools.build_meta" + +[project] +name = "fps-toolkit" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "numpy", + "scipy", + "pydantic", + "matplotlib", + "fastapi", + "uvicorn", +] + +[project.optional-dependencies] +dev = ["pytest"] + +[project.scripts] +fps-simulate = "fps.simulate:main" diff --git a/src/fps/__init__.py b/src/fps/__init__.py new file mode 100644 index 0000000..8742f84 --- /dev/null +++ b/src/fps/__init__.py @@ -0,0 +1,7 @@ +"""FPS Simulation Toolkit package.""" + +from .simulate import FPSSimulation +from .parameters import RunConfig +from .server import app as server_app + +__all__ = ["FPSSimulation", "RunConfig", "server_app"] diff --git a/src/fps/metrics.py b/src/fps/metrics.py new file mode 100644 index 0000000..539494d --- /dev/null +++ b/src/fps/metrics.py @@ -0,0 +1,13 @@ +import numpy as np +from .strata import Stratum + + +def coherence(strata: list[Stratum]) -> float: + phases = np.array([s.φ for s in strata]) + mean_phase = np.angle(np.mean(np.exp(1j * phases))) + return float(np.mean(np.cos(phases - mean_phase))) + + +def effort(strata: list[Stratum]) -> float: + # simple placeholder effort as sum of amplitudes + return float(sum(abs(s.A) for s in strata)) diff --git a/src/fps/parameters.py b/src/fps/parameters.py new file mode 100644 index 0000000..f45fb9b --- /dev/null +++ b/src/fps/parameters.py @@ -0,0 +1,45 @@ +from __future__ import annotations +from pydantic import BaseModel, Field, validator +from pathlib import Path +from typing import Literal + +class FeedbackConfig(BaseModel): + G: Literal["tanh", "damped_sine", "sinc", "custom"] = "tanh" + +class NoiseConfig(BaseModel): + type: Literal["uniform", "gaussian", "real_signal"] = "uniform" + scale: float = Field(0.0, ge=0) + +class StrataDefaults(BaseModel): + A0: float = 1.0 + f0: float = 1.0 + φ0: float = 0.0 + γ0: float = 1.0 + α: float = 0.1 + β: float = 0.05 + λ: float = 0.01 + +class RunConfig(BaseModel): + run_name: str = "baseline" + seed: int | None = None + T: float = Field(..., gt=0) + Δt: float = Field(..., gt=0) + n_strata: int = Field(..., ge=1) + strata_defaults: StrataDefaults = StrataDefaults() + feedback: FeedbackConfig = FeedbackConfig() + noise: NoiseConfig = NoiseConfig() + logging_every_step: int = 10 + log_format: Literal["csv", "hdf5"] = "csv" + + @validator("logging_every_step") + def nonzero(cls, v): + if v < 1: + raise ValueError("logging_every_step must be ≥1") + return v + + def write_seed(self, path: Path = Path("data/seeds.txt")) -> None: + if self.seed is None: + return + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a") as f: + f.write(f"{self.run_name},{self.seed}\n") diff --git a/src/fps/server.py b/src/fps/server.py new file mode 100644 index 0000000..d80ce47 --- /dev/null +++ b/src/fps/server.py @@ -0,0 +1,58 @@ +from fastapi import FastAPI, Response +from pathlib import Path +from io import BytesIO +import csv +import yaml +import matplotlib.pyplot as plt + +from .simulate import FPSSimulation +from .parameters import RunConfig + +app = FastAPI() + + +def _load_cfg(cfg_path: str) -> RunConfig: + with open(cfg_path, "r") as f: + cfg_dict = yaml.safe_load(f) + logging_cfg = cfg_dict.get("logging", {}) + return RunConfig( + run_name=cfg_dict.get("run_name", "run"), + seed=cfg_dict.get("seed"), + T=cfg_dict.get("T", 1.0), + Δt=cfg_dict.get("Δt", 0.01), + n_strata=cfg_dict.get("n_strata", 1), + strata_defaults=cfg_dict.get("strata_defaults", {}), + feedback=cfg_dict.get("feedback", {}), + noise=cfg_dict.get("noise", {}), + logging_every_step=logging_cfg.get("every_step", 10), + log_format=logging_cfg.get("format", "csv"), + ) + + +@app.post("/run") +def run_simulation(cfg_path: str = "config/default.yaml"): + cfg = _load_cfg(cfg_path) + sim = FPSSimulation(cfg) + sim.run() + return {"status": "ok", "run_name": cfg.run_name} + + +@app.get("/plot/{run_name}") +def plot(run_name: str): + log_path = Path("data/logs") / f"{run_name}.csv" + if not log_path.exists(): + return {"error": "log not found"} + t, C = [], [] + with log_path.open() as f: + r = csv.DictReader(f) + for row in r: + t.append(float(row["t"])) + C.append(float(row["C"])) + fig, ax = plt.subplots() + ax.plot(t, C) + ax.set_xlabel("t") + ax.set_ylabel("coherence") + buf = BytesIO() + fig.savefig(buf, format="png") + plt.close(fig) + return Response(buf.getvalue(), media_type="image/png") diff --git a/src/fps/simulate.py b/src/fps/simulate.py new file mode 100644 index 0000000..7f39aa2 --- /dev/null +++ b/src/fps/simulate.py @@ -0,0 +1,97 @@ +import numpy as np +import time +import csv +from pathlib import Path +from .parameters import RunConfig +from .strata import Stratum +from .spiral import G_factory +from .metrics import coherence, effort + + +class FPSSimulation: + def __init__(self, cfg: RunConfig): + self.cfg = cfg + self.G = G_factory(cfg.feedback.G) + rng = np.random.default_rng(cfg.seed) + self.noise = rng + self.strata = [ + Stratum( + cfg.strata_defaults.A0, + cfg.strata_defaults.f0, + cfg.strata_defaults.φ0, + cfg.strata_defaults.γ0, + cfg.strata_defaults.α, + cfg.strata_defaults.β, + cfg.strata_defaults.λ, + ) + for _ in range(cfg.n_strata) + ] + self.steps = int(cfg.T / cfg.Δt) + self._prepare_logging() + cfg.write_seed() + + def _prepare_logging(self) -> None: + self.rows: list[tuple[float, float, float, float]] = [] + self.t0 = time.perf_counter() + + def run(self) -> None: + for k in range(self.steps): + t = k * self.cfg.Δt + for s in self.strata: + I = self.noise.uniform(-1, 1) * self.cfg.noise.scale + feedback = self.G(I) + s.update(I, feedback, self.cfg.Δt) + if k % self.cfg.logging_every_step == 0: + self._log_step(t) + self._dump() + + def _log_step(self, t: float) -> None: + C = coherence(self.strata) + E = effort(self.strata) + cpu = (time.perf_counter() - self.t0) / (len(self.rows) + 1) + self.rows.append((t, C, E, cpu)) + + def _dump(self) -> None: + out = Path("data/logs") + out.mkdir(parents=True, exist_ok=True) + fname = out / f"{self.cfg.run_name}.{self.cfg.log_format}" + if self.cfg.log_format == "csv": + with fname.open("w", newline="") as f: + w = csv.writer(f) + w.writerow(["t", "C", "effort", "cpu_step"]) + w.writerows(self.rows) + else: + import h5py + + with h5py.File(fname, "w") as h5: + h5.create_dataset("dataset", data=np.array(self.rows)) + + +def main(argv=None) -> None: + import argparse, yaml + + parser = argparse.ArgumentParser(description="Run FPS simulation") + parser.add_argument("--config", default="config/default.yaml") + args = parser.parse_args(argv) + + with open(args.config, "r") as f: + cfg_dict = yaml.safe_load(f) + logging_cfg = cfg_dict.get("logging", {}) + cfg = RunConfig( + run_name=cfg_dict.get("run_name", "run"), + seed=cfg_dict.get("seed"), + T=cfg_dict.get("T", 1.0), + Δt=cfg_dict.get("Δt", 0.01), + n_strata=cfg_dict.get("n_strata", 1), + strata_defaults=cfg_dict.get("strata_defaults", {}), + feedback=cfg_dict.get("feedback", {}), + noise=cfg_dict.get("noise", {}), + logging_every_step=logging_cfg.get("every_step", 10), + log_format=logging_cfg.get("format", "csv"), + ) + sim = FPSSimulation(cfg) + sim.run() + + +if __name__ == "__main__": + main() diff --git a/src/fps/spiral.py b/src/fps/spiral.py new file mode 100644 index 0000000..dce73d2 --- /dev/null +++ b/src/fps/spiral.py @@ -0,0 +1,12 @@ +import numpy as np +from typing import Callable + + +def G_factory(kind: str) -> Callable[[float], float]: + if kind == "tanh": + return lambda x: np.tanh(x) + if kind == "damped_sine": + return lambda x: np.exp(-np.abs(x)) * np.sin(x) + if kind == "sinc": + return lambda x: np.sinc(x / np.pi) + raise ValueError(f"Unknown G kind: {kind}") diff --git a/src/fps/strata.py b/src/fps/strata.py new file mode 100644 index 0000000..6ccb6c4 --- /dev/null +++ b/src/fps/strata.py @@ -0,0 +1,19 @@ +import numpy as np +from dataclasses import dataclass + +@dataclass +class Stratum: + A: float + f: float + φ: float + γ: float + α: float + β: float + λ: float + + def update(self, I: float, feedback: float, Δt: float) -> None: + """Update state according to Eq. (1) discretisation.""" + I_filt = 1 / (1 + np.exp(-I)) + self.A += self.α * I_filt - self.β * feedback + self.f += self.λ * feedback + self.φ = (self.φ + 2 * np.pi * self.f * Δt) % (2 * np.pi) diff --git a/src/fps/visualise.py b/src/fps/visualise.py new file mode 100644 index 0000000..8633c26 --- /dev/null +++ b/src/fps/visualise.py @@ -0,0 +1,16 @@ +import csv +from pathlib import Path +import matplotlib.pyplot as plt + + +def plot_log(path: str | Path) -> None: + t, C = [], [] + with open(path, newline="") as f: + r = csv.DictReader(f) + for row in r: + t.append(float(row["t"])) + C.append(float(row["C"])) + plt.plot(t, C) + plt.xlabel("t") + plt.ylabel("coherence") + plt.show() diff --git a/src/tests/test_metrics.py b/src/tests/test_metrics.py new file mode 100644 index 0000000..a277947 --- /dev/null +++ b/src/tests/test_metrics.py @@ -0,0 +1,7 @@ +from fps.metrics import coherence +from fps.strata import Stratum + + +def test_coherence_single(): + s = Stratum(1.0, 1.0, 0.0, 1.0, 0.1, 0.05, 0.01) + assert coherence([s]) == 1.0 diff --git a/src/tests/test_simulate.py b/src/tests/test_simulate.py new file mode 100644 index 0000000..80a41c9 --- /dev/null +++ b/src/tests/test_simulate.py @@ -0,0 +1,14 @@ +from fps.parameters import RunConfig +from fps.simulate import FPSSimulation + + +def test_simulation_runs(tmp_path): + cfg = RunConfig(T=0.1, Δt=0.05, n_strata=1) + cfg.logging_every_step = 1 + cfg.log_format = "csv" + cfg.run_name = "test" + cfg.write_seed(tmp_path / "seeds.txt") + sim = FPSSimulation(cfg) + sim.run() + log = tmp_path / "logs" / "test.csv" + assert log.exists() diff --git a/src/tests/test_spiral.py b/src/tests/test_spiral.py new file mode 100644 index 0000000..34cd5a3 --- /dev/null +++ b/src/tests/test_spiral.py @@ -0,0 +1,6 @@ +from fps.spiral import G_factory + + +def test_tanh_monotonic(): + G = G_factory("tanh") + assert G(0.1) > G(-0.1) From ab5cecd2b4122676f3e33526751890a8c755d3d7 Mon Sep 17 00:00:00 2001 From: MellowMango Date: Wed, 21 May 2025 22:27:28 -0500 Subject: [PATCH 2/2] Update rebase.yml --- .github/workflows/main.yml | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4375c15..21cd24e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,20 +1,40 @@ -# .github/workflows/auto-resolve.yml -name: Auto-rebase-prefer-PR +# .github/workflows/auto-rebase-and-merge.yml +name: Auto-rebase-then-merge + on: pull_request_target: types: [opened, synchronize, ready_for_review, reopened] + +permissions: + contents: write # needed for pushing & merging + pull-requests: write + jobs: + rebase: if: github.event.pull_request.draft == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: - ref: ${{ github.head_ref }} # the PR branch + ref: ${{ github.head_ref }} fetch-depth: 0 - run: | git config user.name "merge-bot" git config user.email "bot@example.com" git fetch origin ${{ github.base_ref }} - git merge -s ort -X theirs origin/${{ github.base_ref }} --commit -m "Auto-merge main into PR" + git merge -s ort -X theirs origin/${{ github.base_ref }} \ + --commit -m "Auto-merge main into PR" git push origin HEAD + + merge: + needs: rebase + if: ${{ needs.rebase.result == 'success' }} # CI steps could live here + runs-on: ubuntu-latest + steps: + - name: Squash-merge the PR + uses: peter-evans/merge-pull-request@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + merge_method: squash # or merge / rebase + commit_title: "♻️ Auto-merge PR #${{ github.event.pull_request.number }}"