From bceef96cda9c895b55611f7205903c5b9fe461f5 Mon Sep 17 00:00:00 2001 From: yallioux Date: Wed, 7 Oct 2026 15:12:51 +0200 Subject: [PATCH 1/2] test: keep the licence pattern of a test from being read as a second licence tag Signed-off-by: yallioux --- tests/test_spdx_headers.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_spdx_headers.py b/tests/test_spdx_headers.py index 4ca5f7b..6af0790 100644 --- a/tests/test_spdx_headers.py +++ b/tests/test_spdx_headers.py @@ -76,7 +76,7 @@ def test_every_empty_python_file_is_annotated_in_reuse_toml(): def test_every_licence_used_has_its_text_in_the_licenses_folder(): used = set(re.findall(r'SPDX-License-Identifier = "([^"]+)"', (ROOT / "REUSE.toml").read_text(encoding="utf-8"))) - used |= {m for p in FILES for m in re.findall(r"SPDX-License-Identifier: (\S+)", "\n".join(_header(p)[1]))} + used |= {m for p in FILES for m in re.findall(r"SPDX-License-" r"Identifier: (\S+)", "\n".join(_header(p)[1]))} assert {"LGPL-3.0-or-later", "CC-BY-4.0", "CC0-1.0"} <= used missing = sorted(name for name in used if not (ROOT / "LICENSES" / f"{name}.txt").exists()) assert not missing, missing @@ -90,3 +90,11 @@ def test_every_data_file_is_annotated_in_reuse_toml(): assert data, "no data file found" missing = [name for name in data if f'"{name}"' not in reuse] assert not missing, missing + + +def test_a_file_carries_one_licence_tag_not_a_pattern_that_looks_like_one(): + # `reuse lint` reads every licence tag it finds, in code and strings included: a regular expression written with the tag in one piece made a test file fail it + # ("invalid SPDX license expression"). A file holds its header tag and nothing that looks like another. + tag = "SPDX-License-" + "Identifier:" + several = {p.relative_to(ROOT).as_posix(): n for p in FILES if (n := p.read_text(encoding="utf-8", errors="replace").count(tag)) > 1} + assert not several, several From 1f2ead5ceb08b29278d7a9970e46bfd454899588 Mon Sep 17 00:00:00 2001 From: yallioux Date: Wed, 7 Oct 2026 15:15:18 +0200 Subject: [PATCH 2/2] chore: REUSE workflow, DCO sign-off, pid parameter tests and notes Signed-off-by: yallioux --- .github/dco.yml | 4 +++ .github/pull_request_template.md | 1 + .github/workflows/reuse.yml | 32 +++++++++++++++++++ CHANGELOG.md | 5 +++ CONTRIBUTING.md | 13 +++++++- DCO | 34 ++++++++++++++++++++ LICENSES/LicenseRef-DCO-1.1.txt | 34 ++++++++++++++++++++ REUSE.toml | 7 +++++ architecture/core/06_the_spectrum_api.md | 4 +++ docs/contributing/dco_howto.md | 31 ++++++++++++++++++ tests/spectrum/test_pid.py | 40 ++++++++++++++++++++++++ 11 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 .github/dco.yml create mode 100644 .github/workflows/reuse.yml create mode 100644 DCO create mode 100644 LICENSES/LicenseRef-DCO-1.1.txt create mode 100644 docs/contributing/dco_howto.md diff --git a/.github/dco.yml b/.github/dco.yml new file mode 100644 index 0000000..2e026b9 --- /dev/null +++ b/.github/dco.yml @@ -0,0 +1,4 @@ +# DCO GitHub App: require a sign-off on every commit of a pull request. +# Members of the organisation are checked too (no exemption). +require: + members: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index cfba16d..6de3d88 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,6 +14,7 @@ Fill one row per line you add to `CHANGELOG.md`. "Not needed" is an answer only - [ ] No frozen public API change: existing parameters keep their name, order and default (`tests/test_api_contracts.py`); new ones are keyword arguments with a default. - [ ] No `print()` in new code. - [ ] Data used by tests and examples is public. +- [ ] Every commit is signed off (`git commit -s`, see CONTRIBUTING.md). - [ ] Every added file keeps its `SPDX-FileCopyrightText` and `SPDX-License-Identifier` header; its author is named as `SPDX-FileContributor`. ## Pre-push verdict diff --git a/.github/workflows/reuse.yml b/.github/workflows/reuse.yml new file mode 100644 index 0000000..7ae186c --- /dev/null +++ b/.github/workflows/reuse.yml @@ -0,0 +1,32 @@ +name: REUSE + +on: + push: + pull_request: + workflow_dispatch: + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +concurrency: + group: reuse-${{ github.ref }} + cancel-in-progress: true + +jobs: + reuse: + name: REUSE lint + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install reuse + run: pip install reuse + + - name: Check licences and copyright on every file + run: reuse lint diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b68385..0574516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > **[0.0.6]** corresponds to the legacy `weakl` package available on PyPI. --- +## [Unreleased] + +- The `DCO` sign-off is required on every commit of a pull request (`DCO`, `CONTRIBUTING.md`, `docs/contributing/dco_howto.md`); a `REUSE` workflow runs `reuse lint` on each push and pull request. +- The `pid()` page of `architecture/core/06_the_spectrum_api.md` says when `d_pen` acts and that the derivative is computed along the frame given to `predict()`; three tests pin `pid` behaviour. + ## [1.4.0] - 2026-10-06 First release on PyPI and Zenodo since 1.3.1; it also carries everything of 1.3.2 to 1.3.4, which were tags only. **`AdaptiveTAM`, `KalmanTAM` and `NeuralTAM` [EXP] forecasts change** (see 1.3.3, 1.3.4 and the sections below); `StaticTAM` changes only for `te()` with GCV (-2%), a mixture mean (-0.6%) and a prediction without the group column. To get the 1.3.1 behaviour: `pip install tam-ml==1.3.1`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1955d1..7bf2f13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,4 +61,15 @@ A body of work too large for one pull request (a new model family, a research co 6. **Submit your PR:** Provide a clear description of the problem solved, the mathematical approach taken, and the performance implications. --- -*By contributing to TAM, you agree that your contributions will be licensed under the project's LGPL license.* \ No newline at end of file + +## 6. Licence of Contributions and Sign-off (DCO) + +By contributing, you agree that your contribution is licensed under the licence of the file it changes (LGPL-3.0-or-later for the code, see `LICENSES/` and `REUSE.toml` for the data and documents). + +Every commit must carry a sign-off line, which certifies the [Developer Certificate of Origin 1.1](DCO): + +``` +Signed-off-by: Your Name +``` + +`git commit -s -m "short phrase"` adds it. The `DCO` check of a pull request fails when a commit lacks it. How to fix a missing sign-off: [docs/contributing/dco_howto.md](docs/contributing/dco_howto.md). diff --git a/DCO b/DCO new file mode 100644 index 0000000..49b8cb0 --- /dev/null +++ b/DCO @@ -0,0 +1,34 @@ +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. diff --git a/LICENSES/LicenseRef-DCO-1.1.txt b/LICENSES/LicenseRef-DCO-1.1.txt new file mode 100644 index 0000000..49b8cb0 --- /dev/null +++ b/LICENSES/LicenseRef-DCO-1.1.txt @@ -0,0 +1,34 @@ +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. diff --git a/REUSE.toml b/REUSE.toml index 8adf197..b865217 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -53,3 +53,10 @@ path = "src/tam/data/airpassengers.csv" precedence = "closest" SPDX-FileCopyrightText = "Box, Jenkins and Reinsel, Time Series Analysis: Forecasting and Control (monthly international airline passengers, 1949-1960)" SPDX-License-Identifier = "CC0-1.0" + +# The Developer Certificate of Origin 1.1, copied verbatim (changing it is not allowed by its own text). +[[annotations]] +path = "DCO" +precedence = "closest" +SPDX-FileCopyrightText = "2004, 2006 The Linux Foundation and its contributors" +SPDX-License-Identifier = "LicenseRef-DCO-1.1" diff --git a/architecture/core/06_the_spectrum_api.md b/architecture/core/06_the_spectrum_api.md index 556509f..2aea02f 100644 --- a/architecture/core/06_the_spectrum_api.md +++ b/architecture/core/06_the_spectrum_api.md @@ -365,6 +365,10 @@ Constructs a $3 \times 3$ diagonal stiffness matrix. Uniquely applies an artific :end-before: "#: " ``` +**Reading the parameters.** `d_pen` multiplies the ridge penalty of the derivative term only, relative to the penalty `ap` of the effect. With the default penalty (1e-9) that ridge is already negligible, so `d_pen` changes nothing; it acts with an explicit penalty (`ap=-2`), where a large `d_pen` penalises the derivative out. `auto_fit` tunes that penalty, and on a formula that already holds the lag's difference it often shrinks the whole effect, which leaves `d_pen` nothing to scale. The tests `test_d_pen_*` in `tests/spectrum/test_pid.py` pin this behaviour. + +**Frame dependence.** The derivative and the rolling mean are computed along the rows of the frame given to `predict()`: the first `w` rows of each group of a frame get values that training never saw. Predict a frame that holds the history (the training rows, then the new rows) and slice the result; a short frame alone gives wrong PID features. With a trending lag that leaves the trained range, the P and I terms extrapolate the raw lag, and `extrapolate='linear'` can be much worse than the default. + ### Control Diagnostics (Bode Stability) **Core Interface:** `bode.py` diff --git a/docs/contributing/dco_howto.md b/docs/contributing/dco_howto.md new file mode 100644 index 0000000..f791060 --- /dev/null +++ b/docs/contributing/dco_howto.md @@ -0,0 +1,31 @@ +# Signing off your commits (DCO) + +The sign-off says you wrote the change, or have the right to submit it, under the licence of the project ([DCO](../../DCO)). It is a line at the end of the commit message. + +## New commits + +```bash +git commit -s -m "fix: short phrase" +``` + +Use the name and email set in `git config user.name` and `git config user.email`; they must match your commits. + +## The last commit lacks it + +```bash +git commit --amend -s --no-edit +git push --force-with-lease +``` + +## Several commits of the branch lack it + +```bash +git rebase --signoff main +git push --force-with-lease +``` + +Replace `main` by the branch your pull request targets. Only force-push your own feature branch. + +## The check still fails + +Open the `DCO` check of the pull request: it lists the commits without a sign-off and gives the commands above for them. diff --git a/tests/spectrum/test_pid.py b/tests/spectrum/test_pid.py index 59afb91..93edbe2 100644 --- a/tests/spectrum/test_pid.py +++ b/tests/spectrum/test_pid.py @@ -41,3 +41,43 @@ def test_pid_zero_window_uses_full_cumulative_integral(normalized): def test_pid_negative_window_raises(): with pytest.raises(ValueError, match="non-negative"): PIDEffect("x", window=-1, lambda_p=1.0, d_penalty_multiplier=1.0, extrapolate="continue") + + +def _derivative_signal(n=1500, n_train=1000): + """A stationary AR(1) ``x`` whose derivative carries the information: ``y = x + 5 dx + noise``.""" + import numpy as np + import pandas as pd + + rng = np.random.default_rng(0) + x = np.zeros(n) + for t in range(1, n): + x[t] = 0.8 * x[t - 1] + rng.normal() + y = x + 5 * np.diff(x, prepend=x[0]) + rng.normal(0, 0.3, n) + df = pd.DataFrame({"timestamp": pd.date_range("2022-01-01", periods=n, freq="h"), "grp": "G", "x": x, "y": y}) + return df, n_train + + +def _test_rmse(formula): + import numpy as np + + df, n_train = _derivative_signal() + model = tam.StaticTAM(formula=formula, group_col="grp", date_col="timestamp") + model.fit(df.iloc[:n_train]) + pred = model.predict(df)["Estimatedy"].to_numpy()[n_train:] + return float(np.sqrt(np.mean((pred - df["y"].to_numpy()[n_train:]) ** 2))) + + +def test_pid_beats_a_linear_term_when_the_derivative_carries_the_signal(): + assert _test_rmse("y ~ pid(x, w=6, d_pen=0)") < 0.2 * _test_rmse("y ~ l(x)") + + +def test_d_pen_is_hidden_by_the_default_penalty(): + """With the default (negligible) penalty, d_pen multiplies a ridge that is already ~0: the forecasts agree.""" + low, high = _test_rmse("y ~ pid(x, w=6, d_pen=0)"), _test_rmse("y ~ pid(x, w=6, d_pen=100)") + assert abs(low - high) < 0.01 * low + + +def test_d_pen_acts_when_the_penalty_is_explicit(): + """With an explicit penalty (ap=-2) a large d_pen penalises the derivative out and the forecast gets worse.""" + low, high = _test_rmse("y ~ pid(x, w=6, d_pen=0, ap=-2)"), _test_rmse("y ~ pid(x, w=6, d_pen=100, ap=-2)") + assert high > 1.1 * low