Repository navigation
Conversation
* implement most of issue 1 * update cp_surface * correct period meaning * update docstrings * finish issue 1 * minor comment updates * remove turbine_key mirrors change in resgroup/hill-of-towie-open-source-analysis#53 * address PR comments * Update hill_of_towie.py
* issue 2 initial effort * Simplify docstring Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * add example_hot_study.py * address PR comments * improve kwargs enforcement --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* issue 3 WIP * Update example_v0_study.py * fix md has different turbines than cfg.asset.wtgs * write out tdf * add missing save_plots guards * fix lint * remove HOT details from era5.py * fix format * fix test_explicit_mplbackend_is_respected * address PR comments
* update issue 4 * WIP * cache per year, turbine * format * improve naive plots * improve naive plots * improve issue 4 descr * rename example prepost * rename example prepost * rename example prepost * run fast methods first * fix TypeError * address PR comment * address PR comment
* refactor out scadadf * improve e2e tests * address PR comments * fix test flake
* update issue text * RLearner WIP * RLearner WIP * RLearner tested against naive * update gitignore * add overnight scripts * fix typo Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * improve overnight scripts * add findings.md * address PR comments --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* add inspect_prepost_hard_case.py * add diagnostics modules * mandatory availability filter * fix naive plot bug * fix docstring Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix docstring Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * update findings.md * address PR comments --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* power_model * update docstrings * improve histograms * address PR comments
* add study_power_model_compare.py * add study_power_model_compare baseline * uplift by condition WIP * update power_model baseline results * add by condition diagnostic plots * add by condition diagnostic plots * update findings.md * plan issues 6-8 * address PR comment
* add prediction clip * add condition dependent comparison * add _select_profiles * add --accept-candidate * update baseline * address pr comments
* add ERA5 future work * issue 8 substantially complete * address PR comments * accept new conditional uplift method * update issue 8 text * address PR comments
* issues 9-11 * address PR comments * explore feature removal * improvements from feature removal * address PR comments
* issue 12 * address PR comments
* issue 13 complete * further investigate half life * address PR comments * skip rlearner e2e tests * address PR comments
* issue 14 WIP * add 1 and 2 months to baseline * accept matched count 50 * issue 14 full benchmark * address PR comments
* issue 15 WIP * re-number issues * re-number issues * fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* prune opt-in behaviour * update docstring
* update findings.md * add power conditional WIP * add power conditional WIP * improve conditional diagnostic plots * add power plot * rerun study_power_model_compare * address PR comment
* Update naive_ratio.py * Update method.py * make benchmarking importable * use shared build_toggle_df * fix log bug * address PR comments
* make columns required * address PR comments
* add ToggleSpecialistMethod * Update test_naive_ratio.py * Update toggle_specialist.py * complete issue 17 * feat: add campaign_weeks grid + toggle-methods regression harness Adds a weeks-based campaign-length grid alongside the existing months grid, and a study script that regression-tests the two toggle-capable methods (toggle_specialist, power_model) on Hill of Towie. The weeks grid is additive: StudyConfig takes exactly one of campaign_months / campaign_weeks, and leaderboard/plot_campaign_curves take a length_col defaulting to campaign_months. Every existing months-based call is therefore bit-identical, so the committed power_model baseline stays valid. months-only accessors (CampaignWindow.months, StudyConfig.max_activity_months) raise on a weeks study rather than silently reporting weeks as months. study_toggle_methods_compare.py scores a placebo plus a symmetric +/-2% Cp pair over 1/2/4/8 weeks -- the small-signal, short-campaign regime a real toggle campaign lives in. It reports raw deltas against a committed benchmark: ground truth is deterministic in (config, seed), so an unchanged method must diff to exactly 0.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * add validate_conditions * Create study_toggle_methods_compare_baseline.json Recorded on f6b509b's method code. The stamp reads 04f36d6-dirty because validate_conditions (a pure, unwired addition) landed mid-run and test files were uncommitted; neither method's source differed. * enforce kwargs * add binning to toggle specialist * Update density.py * update vocab * update study_toggle_methods_compare_baseline.json * address PR comments * Update study_toggle_methods_compare.py * Update study_toggle_methods_compare_baseline.json * V1 ts uncertainty (#127) * toggle specialist uncertainty WIP * toggle specialist uncertainty WIP * toggle specialist uncertainty WIP * toggle specialist uncertainty WIP * toggle specialist uncertainty WIP * toggle specialist uncertainty WIP * toggle specialist uncertainty WIP * toggle specialist uncertainty WIP * clean up baseline loose end * address PR comments * add labeled_rows * make power bins symmetric * address PR comments * format --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* exclude_row WIP * address PR comments
* WIP * WIP * WIP * address PR comments * address further PR comments * add inspect_wake_steering_case.py * create issues_campaigns.md
…133) Drop the rlearner package entirely, carrying forward only the shared piece power_model still needs. - Relocate make_outcome_model (+ its _COMMON params and lazy lightgbm import) verbatim from rlearner/nuisance.py into power_model/fitting.py; repoint power_model/method.py and inspect_era5_matching_importance.py. Estimator construction is byte-identical, so power_model behaviour is unchanged. - Carry over the factory's direct unit test into test_power_model_fitting.py (TestMakeOutcomeModel) so relocating it does not drop its unit coverage. - Delete the rlearner package, its seven tests, and the rlearner-only inspect_prepost_feature_ablation.py. Rename the "rlearner" fixture labels in test_plots.py (they were plain strings, no import). - Drop the now-stale rlearner references in era5_sync.py and method.py docstrings. Acceptance: poe all-fast green; power_model reads UNCHANGED against both committed benchmarks (study_toggle_methods_compare max delta 0.06 pp; study_power_model_compare 0 material moves), i.e. identical to within LightGBM's same-machine noise floor. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…nt drivers (#134) Post-C7 housekeeping ahead of the campaigns tranche. - Reword all "R-learner" references left behind by the rlearner package deletion (C7) to describe current behaviour. Notably the overnight studies advertised "oracle + naive + R-learner + v0" but now run power_model. - Strip finding-number citations (F1/F5/F13/F30/...) from source and tests per the CLAUDE.md rule; the rationale stays in docs/v1/findings.md. - Move spent one-off / manual inspection drivers into benchmarking/baselines/old/: migrate_toggle_baseline_v2_to_v3, inspect_era5_matching_importance, inspect_short_campaigns, inspect_naive, inspect_v0_run (no tests, not imported by the active tree). poe all-fast green; no scored-method or benchmark behaviour changes. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* W0: src/ layout — rename legacy wind_up to wind_up_v0, add v1 wind_up skeleton Adopt a src/ layout so the v1 package can claim the `wind_up` import name while the legacy tool is retained as `wind_up_v0` (distribution stays `res-wind-up`; only import names change). - move legacy `wind_up/` to `src/wind_up_v0/`; add a minimal `src/wind_up/` v1 skeleton (docstring + __version__ + py.typed) for W1 to fill - repoint every importer (v0_binned baseline, benchmarking, tests, examples, and the smarteole notebook) to `wind_up_v0` - constants.py PROJECTROOT_DIR parents[1] -> parents[2] (repo root is one level deeper) - pyproject packaging (where=["src","."]), coverage source, ruff paths, CODEOWNERS - document Git LFS as a prerequisite; gitignore the smarteole example download Behaviour-preserving: the v0 output-schema key "wind_up_version" is unchanged, poe lint is green (mypy 120 files), 819 tests pass, and the smarteole/wedowind example plots and numbers are byte/pixel-identical before and after. benchmarking/ stays packaged temporarily (imported by a separate project); dropping it from the release artifact and deleting the legacy config/input_data/cache root folders are deferred to W2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * update v1 README * W0 review: fix 3.10 mypy, repoint README to wind_up_v0, harden baseline guards Address PR #135 review feedback: - benchmarking/synthetic/upgrades.py: pin shape-agnostic npt.NDArray[np.float64] annotations on cp_ratio/ws_factor/nacelle_delta and cast the _is_waked argument to float64, so mypy passes under the numpy resolved on Python 3.10. This lint failure predates W0 (it came in with the wake-steering work and the v1 branch was already red on 3.10); the fix is type-only, no runtime change. - README.md: repoint the quick-start imports from `wind_up` to `wind_up_v0`. The `wind_up` package is now the empty v1 skeleton, so the old snippets raised ModuleNotFoundError. - src/wind_up_v0/py.typed: PEP 561 marker for the retained, fully typed legacy API. - test_naive_ratio / test_toggle_specialist: the independence guards now reject both `wind_up` and `wind_up_v0`, so an "independent" baseline cannot quietly import either. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* begin C1 work * C1: campaign declaration + runner + farm uplift + placebo campaign Stands up the whole declaration -> runner -> farm-uplift -> reporting pipeline on a placebo (zero injected uplift) whole-farm campaign, so a campaign is declared rather than hand-wired. - src/wind_up/farm.py: farm_uplift(), the pure headline function, with the capacity-factor cap and non-negativity floor, per-turbine guard flags and spread. - benchmarking/campaigns/: SyntheticCampaign (private, holds the injected upgrades) derives CampaignSpec (public facts only); CampaignRunner; the report; and the two placebo campaigns with their driver. - true_farm_uplift(): the N-turbine generalisation of true_net_uplift. - conditional_truth_vs_estimate moved from inspect_prepost_hard_case into harness/plots.py, so campaigns/report.py does not import the v0 pipeline for a frame-shaping helper. Re-exported, so existing callers are unchanged. Consumers read per-turbine facts through spec.timing_for()/usable_mask() and the mode through spec.mode, so C8's per-turbine change histories change accessor bodies rather than every call site. Results on real Hill of Towie SCADA are logged in docs/v1/findings_campaigns.md (CF1-CF5): truth is exactly 0 in both modes; toggle beats prepost by an order of magnitude; the farm result reaches +0.148% with six test turbines; and T06 is identified as the best failure-mode fixture turbine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D * Review: drop "treated" from src, and "headline" -> "result" PR #136 review. Two terminology changes in the product surface: - TurbineUplift.treated_energy -> actual_energy. The codebase already pairs "actual" with "counterfactual" -- power_model documents its headline as ``sum_actual / sum_counterfactual - 1`` -- and farm.py already had counterfactual_energy, so "treated" was the odd one out. The docstring formula now reads the same way in both places. "actual" is also neutral for the analyses C8 covers that are not upgrades: confirming stable performance, or quantifying a loss event. - "headline" -> "result" throughout, since a farm is not necessarily the top level of aggregation. src/wind_up/ no longer contains "treated". The benchmarking layer keeps it (including the shared treated_mask helpers) for C8 to sweep once the neutral umbrella term is settled, rather than leaving a third vocabulary in play. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D * Fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Review: guard non-finite energy and unusable ratings in farm_uplift Copilot review on #136. The guards accepted non-finite actual energy and non-finite or non-positive rated power, and each failed in its own way: - NaN energy passed with used=True and no guard, then vanished from the result entirely, because the summation skips NaN. The farm number looked clean and n_guarded reported 0. - Infinite energy produced an infinite farm result. - A negative rating made the capacity cap clip the counterfactual to a *negative* value, violating the documented non-negativity floor, and gave a result below -100%. - A NaN rating silently disabled the cap. All four are now dropped with explicit guard reasons (non_finite_energy, invalid_rating), so they are visible in n_guarded and the per-turbine detail rather than silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D * Review: pool each method's farm truth over the turbines it actually used Copilot review on #136. farm_uplift can drop a turbine (a non-finite estimate, say), so a method's farm estimate may cover a subset of the campaign while its truth was pooled over every upgraded turbine. signed_error then subtracted one estimand from another -- and it did so precisely when a method misbehaved, which is when the number matters most. Each farm row's truth is now pooled over that method's used turbines. n_guarded flags the rows where turbines were dropped, since a method that dropped some is not directly comparable with one that used them all. CampaignResult.truth_farm_uplift is unchanged: it remains the all-turbine campaign truth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D * Review: excluded turbines are not candidate references; ERA5 only when needed Copilot review on #136, two independent placebo fixes: - candidate_references included excluded turbines, so the spec simultaneously offered a turbine as a reference and forbade its use. Behaviour was correct because the runner drops those rows, but C3 is "automatic reference selection from the CampaignSpec" and would have read the contradictory field. - build_hot_v0_context fetches ERA5, and only the power model reads it, so include_power_model=False still paid the network dependency. It is now built only when the power model is enabled, which makes the fast path genuinely offline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D * Review: plot only the conditions a method actually reported Copilot review on #136. The report built truth for every condition whenever a method reported any conditional output, so a method reporting one axis still got plots for the others. toggle_specialist reports only "power", so every toggle run wrote ws and ti charts whose method series was entirely NaN while being labelled method-vs-truth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D * C1 scope: v0 is out of scope for the placebo The placebo is a whole-farm campaign and v0 enumerates test/reference combinations per turbine, so a whole-farm v0 run is not tractable. Recording the decision rather than leaving an unmet acceptance criterion: Copilot correctly flagged on #136 that the scope asked for v0 as an optional slow method and the driver did not provide one. The seam still accepts V0BinnedMethod unchanged, so a later campaign over a small turbine subset can include it. C4 still expects v0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D * C8: add the harness "window" disambiguation; track the robustness design C8 scope gains two items that belong with its vocabulary sweep: - Disambiguate "window" in benchmarking/harness/campaign.py, which uses it for two different spans in one docstring: CampaignWindow is the whole baseline-plus-activity span, while its prose says "post window" / "activity window" for the treated part alone. That is the ambiguity C1 renamed `window` to `analysis_period` to escape, so a reader who knows the harness will misread the spec field. - Retire "treated" from the benchmarking layer in the same pass (443 uses, 64 of them the shared treated_mask helpers). src/ is already clear of it. Also drops "treated period" as the example neutral fallback, since the same issue retires that word. The robustness design note was untracked and had picked up the C1 naming pass ("brief" -> "campaign spec"), so those edits existed only in the working tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Mark C0, W0 and C1 done in the campaigns issues list C1 merged as PR #136. C0 and W0 were already complete but unmarked, so the list did not show where the tranche stood. Each now carries a Status line in the same shape C7 uses, and the suggested order marks the finished issues and names C2 as next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D * add spec * C2: campaign context on the method seam Methods learn their candidate references and row validity from a CampaignContext the runner derives from the CampaignSpec, instead of each deriving "every turbine except the test one" -- nine such derivations across four methods, and a declaration nothing read. CampaignContext (harness/context.py) is a narrow per-test-turbine view, so methods are not coupled to the analyst-facing declaration that C8 generalizes and W2 promotes to public API. context_for (campaigns/context.py) is the one translation point, and the one place to audit that no truth reaches a method. Declared validity (exclusions, C8 histories, C5 wake gating) rides the context; screened validity (R3) stays method-internal. valid_for_uplift is named for its purpose: data can be invalid for uplift and still valid for a northing analysis. MethodInput gains a lazily-built default context that is today's implicit contract, so existing call sites are unchanged. Adds ColumnSchema.northed(role) for R1's derived column. Behaviour change: the campaign path now honours the declared candidate_references, so the placebo's six upgraded turbines stop acting as each other's references. Placebo CF1-CF5 in findings_campaigns.md are stale as a result. The study path is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D * C2 review: enforce context invariants, cover declared turbines Addresses the PR 137 review. MethodInput now rejects two silent-failure modes: an input with neither a campaign_context nor an upgrade_timing (which built a context with timing=None and failed later in resolve_toggle), and a context whose test_wtg differs from the input's (which would estimate one turbine while reading another's references and row validity). context_for now covers every declared turbine present in the frame, not just the test turbine and its references, and select() raises rather than silently keeping the rows of a turbine it has no validity for. A method co-analysing several turbines keeps them via select(also=...); those rows were previously exempt from declared validity. Also hoists a set() rebuilt per column in the two wide-format methods. Not changed: build_toggle_df already de-duplicates its index internally and documents itself as indexed by unique timestamps, so passing a long-format index is correct and unchanged from before this branch. Docs: each C-issue with a real Hill of Towie counterpart now runs it alongside the synthetic campaign to test the shape of the declaration (no ground truth, not scored); C3/C4/C5 name their counterpart. W2 gains a YAML-declared CampaignSpec, with those three real campaigns as the acceptance test for "easy to use". C2's done-when records the placebo re-record as its closing step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D * CF6: re-record the placebo under the C2 reference rule C2 made the campaign path honour the declared candidate_references, so the six upgraded placebo turbines no longer serve as each other's references. Re-runs both placebo campaigns and records the result as CF6, controlled against the same driver run from the pre-C2 commit a1f96af. The control reads +0.1485%, matching the +0.148% CF3 recorded, so the comparison is a like-for-like A/B of the reference rule. power_model's prepost farm error falls from +0.148% to +0.039%, while its mean per-turbine absolute error rises slightly (0.481 -> 0.515 pp): the headline gain is cancellation across turbines, not better individual estimates. The mechanism is explicitly not established. Truth is still exactly 0.0 in both modes. CF1 is marked superseded; CF2-CF5 carry a note that they predate the reference rule and are not reproducible as written, their sweeps not having been re-run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D * Re-record the toggle compare baseline (stale since #126/#128) The portable baseline dated from de85f84 (2026-07-15). #126 (V1 toggle specialist) and #128 (custom filter to toggle specialist) then added ~270 lines to toggle_specialist.py without re-recording it, so toggle_specialist had been reading MOVED on every run since: 67 of 84 cells, worst 1.52 pp, against a band of 1e-5 pp. The drift is confined to the conditional per-bin cells (condition == "power"); no overall row moved, so the headline P50 was never affected. That is consistent with those PRs reworking the conditional path deliberately and forgetting the re-record. Not caused by the C2 work on this branch: an A/B with the context calls neutralised is bit-identical, and a control sweep on the pre-C2 commit a1f96af produces the same 19 moved cp_0pct cells to the digit. Recorded from a clean tree at 2e13ac3 on linux, then verified with a fresh compare: power_model UNCHANGED (max delta 0.0531 pp, band 0.1) and toggle_specialist UNCHANGED (max delta 5e-07 pp, band 1e-5) — its effectively bit-exact band is usable again as the sharp instrument for small regressions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* R1: shared northing step, fast DP norther, and the northing fault fixture
Replace v0's ruptures-based hill-climb with an exact dynamic-programming
changepoint search in the new v1 package, wire it in as a shared
preprocessing step upstream of every method, and add the fault injector
and fixture that show a northing step biting and then being closed.
Estimator (src/wind_up/northing.py)
- Daily circular-median aggregation, O(1) prefix-sum segment costs, exact
DP, local refinement, closed-form per-segment offsets. numpy only.
- Two passes preserved: reanalysis anchors the farm in absolute terms
(a uniformly-wrong farm is self-consistent and invisible to pass 2),
the farm consensus then supplies precision.
- Knobs are physical: min_step_deg, changepoints per year, min_segment.
- circular_math moves to wind_up with a wind_up_v0 re-export, so the
releasable v1 package does not depend on the legacy one.
Two effects found by measurement, not foreseen in design
- Reanalysis carries its own direction drift (~4 deg common-mode at
Homer), indistinguishable from every turbine shifting at once, so
against_reanalysis floors min_step_deg at 10 deg.
- Site veer makes a turbine's residual depend on which directions the
wind blew from, so a shift in the direction mix reads as a step. Fixed
at the cause: sector-normalise the residual for detection only, and
iron out small self-cancelling excursions. A step above
max_transient_step_deg is never ironed out, because real
recalibrations do sometimes reverse (T16: 98, 9, 7, 89 deg, net +11).
Effort tiers were built, measured and removed: the speed/quality trade
did not exist (3.1s to 5.2s across a 21-turbine farm-year, in a ~40s run)
and the cheap tier was worse for no saving. One NorthingSettings remains.
Evidence
- Homer ported tests: identical median yaw and max northing error to the
old implementation on all three offsets.
- Hill of Towie, 21 turbines, 2017-2018: 9.9x faster (389.9s -> 39.3s),
and it rediscovers v0's changepoints exactly - {T01: 2, T05: 2,
T16: 3} - plus one on T13, whose worst-case error improves.
- Fixture (T06 + T15/T10/T08, AeroUp uplift, 40 deg step on T15):
prepost bites +1.331 pp and is closed to +0.135 pp, no harm +0.032 pp;
toggle does not bite (-0.025 pp), recorded rather than assumed. Clean
arms discover 0 changepoints, faulted arms exactly 1.
ruptures is dropped from the dependencies, the mypy overrides and the
lockfile.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* R1: fix the record-edge false changepoint, add northing plots
Two findings from comparing against v0's published Hill of Towie table,
plus the plots that make either visible to a user.
Edge false changepoint (fixed)
- The estimator reported a +3.5 deg step on T13 at 2018-12-20 that v0's
table does not have. A window sweep settled it: the step exists only
when the record ends on 2019-01-01, twelve days later. Extend the
record by two days and it is gone.
- Cause: with twelve days after it, the "after" level is a veer-dominated
estimate that landed 3.36 deg from the "before" level, just over the
3.0 deg threshold.
- Fix: scale the required step with the record supporting it. A segment's
level is veer-limited rather than sample-limited and veer averages out
no faster than 1/sqrt(span), so the threshold grows as
sqrt(confident_segment / span), capped at max_transient_step_deg so a
large late jump stays findable.
- A flat "near an edge demand more than 10 deg" rule was tried first and
rejected: it broke T16's genuine +9.0 deg step, which has only 30 days
before it. The scaled rule requires 5.2 deg there and keeps it.
Outage artefact (documented, not fixed)
- Nearly every turbine gains self-cancelling pairs at 2019-11-11/19 and
2020-06-12/19. Those weeks are farm outages: v0's add_wf_yawdir
silently fills a missing farm direction with reanalysis (35% and 51% of
rows; turbine count falls to a median of 2 against the >=3 rule), and
where it does not, the farm median is taken over a different subset of
turbines, which is a different quantity because veer differs per
turbine. The v1 path is already correct on the first mechanism. A
strict-xfail test records the second so it announces itself when fixed.
Tests, written before the fix
- A real-data fixture (git-lfs, 15 MB): timestamp, raw yaw, farm
reference and a reference-fallback flag for six turbines over
2016-2020 -- only what the estimator consumes, so cases run in ~0.3s.
- Known changepoints that must keep being found: T01 x2, T05 x2, T16 x3,
and T07/T11 staying clean.
- Edge artefacts: T13 clean in every window, including the four that
previously produced the false step.
- Synthetic: a large jump ten days from either end is still found; the
same small step is kept well inside the record and dropped near the edge.
Farm-scale result is now an exact match with v0 over 2017-2018: 7
changepoints, same turbines, same dates, steps within 0.65 deg.
Plots (src/wind_up/northing_plots.py)
- Per device: 14-day median residual before and after with the fitted
steps drawn on, and residual by direction sector, where what remains
after correction is site veer.
- Farm overview, and a conditions plot of residual mean and spread
against direction, wind speed and power.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* R1: fix the farm-wide outage artefact -- it was the first pass
Over 2016-2024 nearly every turbine gained self-cancelling changepoint
pairs at 2019-11, 2020-06 and 2023-06: 143 changepoints against v0's 28,
with 111 of the 127 extras in those three months.
Three hypotheses, two of them wrong
- Silent reference substitution (v0's add_wf_yawdir filling a missing
farm direction with reanalysis): removes the Aug 2020 pair and nothing
else.
- Changing reference composition: plausible but measured false. After the
first pass every device's long-run offset from the farm median is
within -0.3 to +0.5 deg, and centring the devices moves the median by
0.00 deg in every window. An attempt to level the reference per sector
made the healthy case worse and was reverted.
- The first pass. During the June 2020 excursion northed-minus-farm is ~0
for every turbine while the raw residual is +20, so the correction the
first pass applied *is* the excursion; it had inserted 2-6 changepoints
per turbine across 2020.
Cause and fix
Reanalysis carries its own direction-dependent bias, so a spell of
unusual wind (that week was easterly, a sector HoT rarely sees) moves
every turbine's residual against it together by tens of degrees. The
first pass corrected for that, writing the excursion into the northed
directions and hence into the farm consensus the second pass trusts. The
outage correlates only because both are weather.
The first pass may now act only on a gross recalibration
(ANCHORING_MIN_STEP_DEG, 30 deg) -- above reanalysis' own excursions and
below a real one (HoT's are 36-177 deg). Everything finer is left to the
second pass, which works against the clean consensus and estimates from
the raw direction, so nothing is lost. Blocking the first pass entirely
was tried and rejected: with four devices one uncorrected 40 deg step
drags the median enough to break a real detection. A quorum (a strict
majority of the farm, not a floor of three) was added alongside; it earns
its place on the subset cases rather than this one.
Measured, 21 turbines, 2016-2024
- changepoints 143 -> 19 (v0 has 28); not in v0's table 127 -> 2
- v0's changepoints recovered 16/28 -> 17/28
- 99-case subset sweep (3 groups x 33 windows, 3 months to 9 years):
cases finding extras 40/98 -> 19/98; total extras 287 -> 32
Tests
The real-data fixture now stores raw inputs rather than a precomputed
farm reference -- the old one had been built with this very bug, so the
tests could not see it. Cases run north_farm end to end over two 2-year
windows (the search costs about the cube of record length, so this covers
the same events for a quarter of the runtime): every v0 recalibration
found, every other turbine silent, no step during either outage, T13
clean wherever the record stops, and west/east halves agreeing with the
whole farm. 52 real-data tests in 31s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* R1: apply the first-pass fix to v0 too, and clear the scar tissue
v0 still had the outage bug
The v0 adapter's first pass called against_reanalysis (10 deg) while the
v1 path had moved to anchoring_only (30 deg), so v0 kept re-detecting the
farm-wide outage excursions the previous commit removed from v1. Exactly
the divergence a shared core exists to prevent. Both now anchor the same
way; the v0 second-pass fallback keeps against_reanalysis, which is right
for it -- that pass still does changepoint work, just conservatively.
Simplification
- anchoring_only wrapped against_reanalysis and then overrode min_step
with 30, which is already above the 10 deg floor, so the inner call was
dead. It now sets the one field it means to.
- sector_signature was made public for the reference-levelling fix that
was measured, found to make the healthy case worse, and reverted. Its
only caller is veer_normalised, so it is private again.
- _farm_quorum sat among the module constants, pages from its only
caller; moved next to _farm_direction.
- The two pruning passes were the same loop written twice -- score every
changepoint, drop the worst, re-estimate, repeat -- and their call
sites shared six of eight arguments. The loop is now _prune_while and
the two rules are small named predicates (_worst_transient,
_worst_unsupported) that read side by side.
west__year_2021 investigated, not fixed
All six extras are T11, whose data coverage in 2021 is healthy
(1.4k-3.9k usable rows a month) and which is the turbine v0 itself
adjusted most: seven entries in its table, every one between 2.2 and 8.6
degrees. Our answer there varies with the window because the evidence is
genuinely ambiguous at that scale, not because of a defect I can point
at. Making the persistence horizon local was tried as a fix and reverted:
it produced byte-identical results on every affected case, so it was a
parameter for nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* Add a Greenbyte source adapter (Kelmarsh, Penmanshiel) and road-test northing on both
Two open Cubico datasets published on Zenodo, exported from Greenbyte in
a shared CSV layout. Both are simpler than Hill of Towie -- six and
fourteen Senvion turbines against HoT's twenty-one -- which makes them a
second and third site for anything that must not be tuned to one farm.
The adapter returns the same long, source-native shape the rest of the
benchmarking layer speaks, with GREENBYTE_COLUMNS in the usual
ColumnSchema vocabulary. Two source quirks are absorbed here, where
source-specific knowledge belongs: availability is published as a
fraction of the period (converted to seconds, as HoT reports it) and
Penmanshiel's static CSV carries a trailing blank row. Zips are found by
globbing rather than by published filename, so a year split across two
files works and so do the shorter names a manual download leaves behind.
Road test of the northing solution (2017-2018, no ground truth; the
question was whether it runs on other data without much effort and
whether it wanders)
turbines load north_farm changepoints monthly range
Kelmarsh 6 8s 1.5s 0 0.5-1.4 deg
Penmanshiel 14 25s 3.9s 0 0.5-4.5 deg
No changepoints on either farm, which is right -- neither has a
documented recalibration in the period -- and a meaningful negative
result given how readily the estimator invented them on HoT before the
recent fixes. No veer-like wandering: Kelmarsh is flat to within 1.4 deg
month to month, and eleven of Penmanshiel's fourteen are under 2 deg.
Penmanshiel's worst (T11, 4.5 deg) is veer being sampled rather than the
corrector moving: its corrected trace sits inside the +/-1 deg band for
two straight years, while its residual by direction sector swings from
-6.8 deg at 135 deg to +2.0 deg at 285 deg, which no single offset can
remove.
Tests build the export layout rather than needing the published zips,
which are hundreds of megabytes: the comment preamble and commented
header, turbine-number padding, UTC index, the availability conversion,
status files ignored, a year split across two zips, multi-year ordering,
the trailing blank metadata row, and the two failure messages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* Land the northing subset-consistency study as a driver
The 99-case sweep that found the outage and edge artefacts only existed
in a session scratchpad, so the exercise could not be repeated after a
change -- which was the point of defining it. The subsets are now
declared in the module (3 turbine groups x 33 windows, 3 months to 9
years, straddling the known recalibrations and the known farm outages),
so two runs are directly comparable.
It reports, per case, how many changepoints were found, how many matched
the full-record run over the same window, how many were invented and how
many lost. On the shipped estimator that reads 19 changepoints for the
full 2016-2024 run against v0's 28, with 19 of 98 cases inventing
anything, total extra 32.
The input frame is built and cached a year at a time: nine years of
21-turbine SCADA at once needs far more memory than the northing itself,
while the handful of columns the estimator reads compresses to tens of MB.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* R1: stop veer normalisation being defeated by its own de-stepping
The sector signature was measured on a residual de-stepped by the first
detection pass. Under veer that pass over-detects, so de-stepping removed
the very sector levels the signature describes -- leaving nothing to
subtract, and the spurious splits survived the pass meant to remove them.
Search the normalised residual first with no step structure assumed, then
re-measure the signature around only the confident steps that search found.
Same number of searches. A real recalibration still dominates the first
search and is still de-stepped; a speculative split is not.
Subset study (99 cases, HoT 2016-2024): extra 32 -> 27, matched unchanged
at 211, runtime 820s -> 541s, reference case byte-identical. The whole
improvement is T11 in 2021 -- six self-cancelling changepoints collapse to
one 3.95 degree step, the window-dependence R1 had left as ambiguity.
Also records CF7: a turbine is inside the farm consensus it is northed
against, which pins pass 2 to pass 1 on small odd farms. Leave-one-out was
built, measured and reverted (better detection, more spurious changepoints);
the regression test stands as xfail(strict).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* Stop tracking development-phase docs (docs/superpowers, CLAUDE.md)
These are local working documents -- design notes, plans, and the Claude
Code guidance file -- not part of the published project. Add both to
.gitignore and remove docs/superpowers from the index; every file stays
on disk.
Tracked files under docs/v1/ still cite these specs by path, so those
citations now resolve only on a development machine. W2 carries a scope
item to resolve that before release: fold what is still true into
docs/methodology.md or docs/v1/, and drop the rest as development history.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* R1: north the study path too, and turn the direction feature on by default
The shared northing step ran only in CampaignRunner, so the study drivers behind
the frozen benchmarks had no northed column and power_model's direction feature
had to ship opt-in. Both paths now north.
The study path norths per replicate, discovering for itself rather than being
handed a table discovered once on the base SCADA. The cheaper variant was
rejected: supplying a prior table is the unrealistic help the benchmark exists
to rule out, and it stays valid only so long as no study profile injects a
direction fault. Northing runs after generation, so a direction-moving upgrade
stays consistent with its northed companion. Measured cost is ~11 s per
replicate on the 4-turbine HoT subset.
The step moves to benchmarking/harness/northing.py -- campaigns imports harness,
so the reverse would be a cycle -- and north_campaign_scada becomes north_scada,
taking the two fields it read (north_offsets, rated_power_kw) instead of a
CampaignSpec the study path does not have. northing_fixture's private
_era5_direction is promoted to era5_direction, the recipe both paths use.
Flipping direction_feature broke 36 tests, all in test_power_model_method.py and
all at __post_init__'s require_roles("nacelle_position"). Three fixture edits
recovered every one: _COLUMNS names the role, and _toy_scada / _shrinkage_scada
carry a site-wide direction each turbine reports through its own miscalibration
plus the northed companion that removes it. No test deleted, no tolerance
loosened.
study_power_model_compare_baseline.json is NOT yet regenerated, so the committed
benchmark is stale against this code. The placebo also moves: it built its
methods without direction_feature, so it has been running with the feature off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* R1: address PR 138 review -- circular_math tests and typing, src comment style
Migrate the circular-math unit tests off the v0 re-export and into tests/wind_up:
test_math_funcs -> test_circular_math, plus the two rolling-window modules, all now
importing wind_up.circular_math. Fills a gap while there: circ_median's axis
parameter was documented and had its own apply_along_axis branch but no test, so
add axis=0 and axis=1 cases on data straddling 0/360.
Type circ_diff and circ_median the way numpy functions are typed -- ArrayLike in,
ndarray-or-scalar out -- which covers float scalars and pandas Series. The explicit
list type goes, and with it the isinstance branch in circ_diff: np.subtract handles
lists directly and keeps Series in, Series out. Behaviour is unchanged.
Strip markdown bold from src/wind_up/northing.py and northing_plots.py, and cut the
comments that justify rather than describe -- the three min-step constants carried
four-line rationales each, including a measured claim that will drift. What a caller
needs to use the API correctly stays: offsets are absolute so tables compose, and
the two pruning passes are order-dependent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* R1: address Copilot review on PR 138
Five findings, each verified against the code before acting on them.
north_scada now raises when a declared table's first offset for a turbine begins after
the data starts. apply_north_table extends the first value backward, so such a table
silently corrected the earliest rows with a later offset. A turbine with no declared
offset is still left alone; the vendored Hill of Towie table starts every turbine at
2016-01-01, so nothing that supplies it is affected.
CampaignRunner always calls north_scada. The _should_north predicate skipped northing
when north_offsets was None and no ERA5 was supplied, which contradicts None meaning
"discover" and deferred the failure to whichever method wanted the northed column.
north_scada already raises an actionable error for that case.
The Greenbyte loader's default column set omitted gen_rpm, although the adapter declares
the role and generate_dataset reads it unconditionally -- so default-loaded data could
not enter the synthetic pipeline. Verified the column is published for Kelmarsh, and
that a default load now feeds generate_dataset.
The real-data northing tests skipped on Path.exists(), which is true for an unsmudged
git-lfs pointer; they then tried to parse the pointer as Parquet. Check the Parquet
magic bytes instead.
Delete baselines/old/inspect_short_campaigns.py: it builds a power model and calls
score_study without era5_wd, so it cannot run now the direction feature defaults on.
The other four modules under old/ do not construct a PowerModelMethod and still work.
Note it is still cited by docs/v1/issues.md and findings.md as the provenance of past
measurements; those citations now dangle, which W2's documentation cleanup covers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* Mark the heavy integration tests slow so the fast gate stays quick
`pytest -m "not slow"` had grown to a few minutes, which is too slow to run between
edits. Marked from measured durations rather than guesswork:
- tests/benchmarking/campaigns/test_placebo_end_to_end.py, at module level -- eight
full campaign runs and by far the largest single block. Matches the existing
treatment of the other *_end_to_end modules.
- the heavy real-data northing sweeps, and the plot-writing diagnostics cases.
- the wake-steering example driver, and four stragglers in the v0 tests.
Coverage the fast gate keeps deliberately: real Hill of Towie data still runs there
(the outage, edge and single-turbine-against-reanalysis cases), and so do five of the
thirteen diagnostics tests. The intent was to defer long integration runs, not to stop
exercising real data between edits.
Timings were taken while a benchmark sweep had the machine, so the absolute target
still needs confirming on an idle box; the deselected count went from 25 to 92.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* R1: add the natural-case northing probe
Sizes the naturally occurring instance of the failure mode, with nothing injected. T06's
nearest neighbour T05 carries real step changes in its reported yaw direction across
2017-2018, so using T05 as a reference exercises a real northing fault; the probe runs the
same campaign with the shared step off and on and reports how far the answer moves.
Two reference sets, because dilution is part of the answer: T05 alone, where it drives the
reference direction entirely, and T05 alongside the fixture's three stable neighbours, where
its effect is diluted. The AeroUp uplift shape is injected so truth is non-zero and the number
reported is an error rather than placebo drift, which also makes it comparable to the
fixture's clean column.
v0 is out of scope here -- the new norther has already been shown to track v0 across the
farm-scale HoT comparison and SMARTEOLE, so the probe runs power_model with naive_ratio as a
control that reads no direction and should not move between arms.
Kept separate from northing_fixture rather than parameterising it: that module produced R1's
acceptance evidence and hardcodes its turbine set in several places.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* R1: the natural probe's minimum reference set needs three devices
The first run died on the T05-alone arm: north_farm forms its consensus from at least three
devices, and the test turbine plus one reference is two. Replace that set with T05 plus one
stable neighbour, which keeps the dilution contrast the probe is for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* Re-record the toggle power_model benchmark for the direction feature
R1 turned power_model's reference-direction feature on by default and made the study path
north per replicate, so this benchmark no longer described the model it was recorded from.
Re-recorded at 7e1c77a.
Checked before accepting. The headline is effectively unmoved: over the twelve `overall`
cells the mean change in score is +0.005 pp, worst cell 0.27 pp. Across the sixty
`power`-condition cells in real power bins it is -0.023 pp. The whole of the "MOVED" verdict
comes from the twelve cells in the near-zero-power bin (-230, 230] kW, which average +0.845 pp
and hold the worst cell -- the bin where the turbine is barely generating and the counterfactual
model has least signal to go on. That mirrors the sparse-bin behaviour in the power_model
benchmark and is not a headline regression.
The portable baseline is deliberately untouched: toggle_specialist reads no direction signal,
and the run confirmed it at a maximum change of 5e-07 pp, so the script left that file alone.
Only the per-platform linux file, which holds power_model's non-portable cells, is rewritten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* Define R5 (northing refinement) and record how to update the frozen benchmarks
R5 captures the two places R1's norther is known to fall short, both found while doing R1
rather than guessed at. Part A: north one or two devices with pass 1 alone, since the farm
consensus refuses below three devices and a two-device campaign therefore cannot be northed at
all. What limits pass 1 is reanalysis' own direction-dependent bias, which is why only gross
steps may be attributed against it; the development loop is unusually good, because Hill of
Towie offers 21 turbines with a published table to score a one-turbine-at-a-time answer
against. Part B: a third pass that nudges the solution toward absolute truth using apparent
wake nadirs, whose direction is fixed by layout geometry and owes nothing to a reanalysis
model. It runs after the changepoints and relative steps are settled, and outputs an offset
only -- it must not quietly change which rows downstream analysis treats as valid.
The benchmark section records what this session cost to learn. There are four frozen
baselines rather than one, and a shared feature-engineering step moves all of them. A
candidate recorded from a dirty tree is refused, so commit before sweeping. Diffing against a
baseline recorded weeks ago measures every commit since, so isolate the change with a
feature-off re-run before accepting -- here that retired a real worry in under an hour. The
MOVED verdict is blunt: degenerate bins dominate the means while their medians sit at zero.
The comparison CSV is in fractions while the logs are in percentage points. And a method that
reads none of the changed signal is a free control worth predicting in advance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* Re-record the power_model benchmark for the direction feature
R1 turned power_model's reference-direction feature on by default and made the study path
north per replicate, so the benchmark recorded on 2026-07-16 at 4f07d64 no longer described
the model it was measured from. Re-recorded at cbe3297.
The change was attributed before being accepted, rather than inferred from a diff against a
seven-week-old baseline. The same sweep was run twice on the same code and seeds, once with
direction_feature on and once with --method-overrides '{"direction_feature": false}'; northing
runs in both arms, so the only difference is whether the model reads the northed direction.
The feature-off arm reproduced the July baseline to within float noise -- mean absolute change
over the thirty-five headline cells of 0.00001 pp prepost and 0.00000 pp toggle, worst single
cell 0.00013 pp. So the six intervening commits that touched power_model, C7's outcome-model
relocation and CampaignContext among them, were behaviour-preserving, and the whole of the
movement is the feature.
The feature itself is neutral on the headline: mean change in score over the overall cells is
-0.024 pp prepost and +0.016 pp toggle, both inside the script's 0.1 pp materiality band.
Within prepost it is not uniform -- one-month campaigns improve by 0.668 pp and two-month ones
worsen by 0.417 pp -- but it nets out near zero. The large per-condition means are sparse bins:
the TI and wind-speed conditions average +8.0 and +2.3 pp against medians of -0.09 and 0.00,
and every large cell sits in TI 0.4-0.5 or wind speed 0-2 m/s, hitting all seven profiles at
the same campaign length, which is the signature of a near-empty bin rather than a regression.
R1 turns the feature on for robustness to a northing fault, which the fixture 2x2 demonstrates.
What this establishes is the other half: it costs nothing on clean data.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* Mark R1 done, and close the example re-runs as redundant
Records the two Done-when items settled by decision rather than built. v0's arm is dropped:
the fixture never ran V0BinnedMethod, accepted because the norther tracks v0 through the
21-turbine farm-scale comparison, the SMARTEOLE road-test and the natural probe's rediscovery
of v0's published T05 table.
The example re-runs are closed as redundant rather than left as debt. Flipping
optimize_northing_corrections would add no coverage: v0's auto path already has six tests
through the adapter including injected changepoints, plus those three comparisons, and the
supplied-table path the examples actually ship is covered by the SMARTEOLE and WeDoWind
end-to-end tests.
W2's example item gains the consequence: northing changes shape in the migration, because the
v0 examples pin a pre-computed table while v1's shared step discovers by default, so a migrated
example should show discovery rather than port the pinned table across.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* Placebo discovers its own northing, and the shared step draws its plots
The placebo supplied the vendored Hill of Towie table, so it applied a known answer and never
exercised the norther. It now declares no table and discovers, which is what a placebo on real
data should demonstrate.
Wire the northing plots in while doing it. src/wind_up/northing_plots.py existed but had no
callers and no tests, so R1's "the tool shows its working" was not actually delivered: the
functions were there, nothing invoked them, and a user running a campaign got no plots.
north_scada now takes out_dir and, whenever it discovers, writes the farm overview plus one
plot per device; CampaignRunner passes northing_out_dir through.
Measured on the real placebo window, 21 turbines over 2017-2018: the norther found seven
changepoints and the vendored v0 table holds seven inside that window -- the same seven, on the
same turbines (T01 twice, T05 twice, T16 three times), three of them to the exact ten-minute
record and none more than 12h40m out, with every offset within 0.75 degrees. Given no prior
table at all, it reproduces the published one across a whole farm.
ERA5 is now fetched whether or not the power model runs, since it is the anchor discovery needs.
The end-to-end placebo test is unaffected: its fixture ships no nacelle position, so the step
returns early on the absent role rather than reaching the reanalysis requirement.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* Record that the campaigns test v1 wind-up, not a field of methods
The "methods carried forward" ground rule listed oracle, naive_ratio, power_model and
toggle_specialist flat, as if they were peers. They are not: the C-series exists to exercise
the thing being shipped, so a campaign has to run it as a user would get it -- power_model on,
and northing discovered rather than supplied. naive_ratio is a deliberately simple yardstick
and never a candidate for the shipped method; oracle is a sanity anchor. toggle_specialist
stays TBD, to be settled with evidence in W1.
The rule now says so, and says the consequence: a campaign that turns power_model off, or that
supplies a north table, is testing something other than v1 wind-up and its result should be
read that way. Tests may switch power_model off to avoid the ml dependency; drivers should not.
The include_power_model docstrings say the same where someone would reach for the flag.
Also removes _north_offsets from placebo.py a second time. It came back after the previous
commit with its imports gone, so it referenced undefined names and had no callers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* Northing needs a real anchor, and writes the table it discovered
Two findings from review on PR 138.
north_farm accepted a reanalysis series that was entirely missing. Pass 1 then fell back to a
zero offset per device with only an INFO line, and pass 2 went on to produce perfectly
plausible relative corrections from the farm consensus alone. Reproduced on a three-device
farm: an all-NaN reanalysis recovered the injected +25 and -40 exactly, anchored on nothing. A
farm uniformly wrong would come back all zeros and look flawless, which is the failure the
two-pass design exists to prevent, and a misaligned ERA5 index reaches it by accident. It now
raises unless some device has a usable row where the reanalysis is finite, and warns when
fewer devices than the quorum do -- "there is an anchor" rather than "the array is non-empty",
since a series that is 95 percent NaN is just as unanchored.
The shared step also discarded the tables it discovered, so nothing downstream could see what
had been decided. It now writes northing_corrections.yaml beside the plots, in the format v0
wrote and both north_offsets and v0's northing_corrections_utc read, so an analyst can inspect
it, hand edit it, and supply it back as a prior. Verified on the real placebo farm: the file
parses to the same list-of-three shape as the vendored table and round-trips into
(turbine, Timestamp, float) tuples.
C5 gains the third finding rather than a fix. WakeSteering moves the reported nacelle position
on treated rows and yaw_usable screens only on power and downtime, so steered rows enter the
northing fit. Excluding treated rows is the obvious answer and is wrong in general: in prepost
they are half the record, and a north step inside the campaign is exactly R1's fault, so
excluding them would make it undiscoverable. The note records what limits the damage today,
that the bias is unmeasured, and what has to be settled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* CF9: record the placebo results with power_model after R1
The placebo numbers on record (CF6) predate R1, so they describe a power_model that read no
direction and a campaign handed the vendored north table. Re-run on the same configuration
with what v1 wind-up now is: the direction feature on, and northing discovered.
Mean per-turbine error falls from 0.515% to 0.312% prepost and from 0.328% to 0.255% toggle.
naive_ratio and toggle_specialist reproduce CF6 to every recorded digit on every turbine in
both modes, so nothing but power_model moved and the comparison is clean.
T06 lands at +0.044% prepost, which matters beyond the average: it is the R-series fixture
turbine, picked in CF5 for accuracy and stability, and it is now essentially exact on a real
placebo.
The prepost farm number drifts the other way, +0.039% to +0.076%, which is the mirror of CF6:
better individual estimates leave less residual to cancel. Both sit well inside the ±0.2%
target. Toggle improves on both axes.
Worth recording because the frozen benchmarks called the direction feature neutral. They
measure four-turbine synthetic campaigns; the placebo is 21 real turbines whose record
contains real northing faults, which is where a north-calibrated direction can contribute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* Drop the unused northing diagnostics, and put v1 in the coverage gate
circular_spread, plot_residual_conditions and their two helpers had no caller
anywhere -- leftovers from the R1 investigation.
The coverage run only ever named wind_up_v0, so nothing in src/wind_up was
measured. Adding it showed northing_plots.py at 13%: no test passed out_dir, so
the plots never ran. Smoke-test the two survivors the way v0's plots are tested,
and omit the module from the report the way v0's plots are omitted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* A sector level must belong to the row that reads it
_sector_signature filled `sector` only where the value was finite, but indexed it
for every row with a finite reference, so a row whose value was NaN silently took
sector 0's level. Every present caller masks those rows out, so nothing moved --
the real-data regressions are unchanged -- but the next caller would have been
bitten. Derive the sector from the reference alone.
The two prune call sites splatted `**bounds` and `**rule`, which mypy checks not
at all: renaming a key produced no error. Spell the arguments out. Binding the
sector width to a local retires the type: ignore alongside.
Docstrings that argued rather than described are cut back. The one piece of
measured evidence among them -- the dropped low-effort tier -- is recorded as
CF10 rather than lost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* Say when the farm reference falls back, and stop hardcoding the outage count
The fallback log was gated on the settings object changing, so it stayed silent
whenever min_step_deg was already at the reanalysis floor -- the fallback had
happened and nothing said so. Gate it on the condition itself.
v0's northing YAML writer duplicated write_north_table_yaml; delegate to it. The
output is unchanged but for a trailing newline, which nothing reads.
test_the_outage_years_are_quiet asserted a literal 4 that had to be kept in step
with EXPECTED by hand. Derive it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D
* tidy docstring
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* R2: unstable-sensor faults, measured on the placebo fixture Define the R2 failure mode and measure what it costs wind-up today. No power_model change: the headline is already immune, and that is the finding. Adds SensorGainStep and SensorGainDrift, which scale a schema role family (the anemometer channels by default) by a constant or a linear ramp. Mean and SD scale together, so turbulence intensity is invariant and only the wind-speed axis moves. Power is untouched, so ground truth never moves. The sensor_fixture driver runs 28 arms on a placebo of T06 plus T15/T10/T08 (truth exactly 0): two shapes x gains x1.5/x0.5 x two targets x both modes, plus an exposed set carrying reference anemometry as features. Supporting passthroughs: faults= on placebo_campaign, reference_stat_cols= on carried_forward_methods. Results (CF11): power_model moves at most 0.214 pp in prepost and exactly zero in toggle. The one nonzero arm is the ERA5 lag sweep tipping one row (10 min) under a x0.5 reference gain, which is why step and drift agree to six decimals. The exposed arm moves 34 pp, so the standing no-reference-anemometer-features rule is now measured rather than assumed. The conditional ws axis moves hard (it is the test turbine's own anemometer); left alone deliberately while that machinery is nascent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D * R2 review: dedupe resolved fault columns so a repeated role cannot square the gain `roles` is public and a ColumnSchema may name the same column for two roles, so `roles=("wind_speed", "wind_speed")` scaled that channel twice and applied gain**2 rather than the documented gain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D * Fix the Python 3.10 lint and test failures on v1 Both are numpy-version artefacts: uv resolves numpy 2.2.6 for 3.10 and 2.4.6 for 3.13, which is why only the 3.10 jobs fail. lint: under 2.2.6 `np.full(n, nan)` types as ndarray[tuple[int], ...] while `np.where` returns ndarray[tuple[int, ...], ...], so the accumulator assignment is rejected; 2.4.6 widens it. Annotate the accumulator. test: with identical turbines naive_ratio divides two mathematically equal sums, and 2.2.6's summation order puts the quotient an ULP off 1.0, so the estimate reads 2.22e-16 rather than 0.0. The assertion demanded exact float equality; compare against a 1e-12 bound instead, which is ten orders of magnitude below any uplift worth reporting and still catches a real bias. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* R3: invalid references — fault, fixture and reference-validity screen Phase A. `ReferenceCpChange` injects an undeclared Cp change into a reference turbine. It changes real power, so `faults.py`'s contract is re-cut around what actually holds for all four faults: injected after the declared upgrades, invisible to the analyst, never moves the ground truth. That last part is enforced rather than documented — `Fault` gains `changes_power` and `generate_dataset` refuses a power-changing fault aimed at a test turbine, which would otherwise be absorbed into the truth derived for it. `benchmarking.campaigns.reference_fixture` declares the arms: a 3-reference pool (clean, +5%, -4%) and a 5-reference pool (clean, two references at -4%). The degradation is the realistic case — a reference is likelier to pick up a problem of its own than an unannounced improvement — so the larger pool uses it. In prepost the change lands at the changeover; in toggle, halfway through the test period, since a change predating a toggle test is common-mode across its blocks. The fault bites: at 3% it moved `power_model` -0.69 to -1.14 pp in prepost and -0.31 to -0.52 pp in toggle (CF12). Phase B, in progress. The screen estimates each candidate reference as if it were a test turbine against the others, and rules out clear outliers from what is normal for this farm over this period — not turbines that changed. A pack that drifts together reads ~0 on each other, correctly, since the test turbine would have drifted too. Deviation from the pool's median, an absolute floor, worst one dropped per pass. A screened reference is made *power-free* rather than removed: it keeps its direction features and gains a `waking` boolean, so the wake information its operating state carries survives while the channels a performance change corrupts do not. Screening always uses a prepost contrast — a reference is not the thing being toggled, so an on-block/off-block question about it answers nothing. Still open: `screen_floor` is a placeholder pending calibration, and the remediation A/B (drop / direction-only / direction+waking) has not been run. Committed at the user's request without waiting for the full suite; lint is clean and the touched test modules pass (40 fixture, 28 features, 26 screening, 11 method-screen). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: report reference uplift and screening detail; fix waking dtype The waking column was bool, which collapses to object dtype once reindexed onto the full timestamp index, and the outcome model rejects that — it crashed the fixture run. It is now tri-state as a float: waking, not waking, or unknown where the reference has no record. Unknown is the honest third state; asserting not-waking there would invent a fact. `power_model` now reports what the screen did and what the references read. `MethodOutput` gains two additive optional frames: `screen_passes` (per pass, per turbine: estimate, deviation, whether it was dropped) so an analyst can see and disagree with the screen's decisions, and `reference_uplifts` (per reference: uplift, actual energy, record count, whether screened). Reference uplift is a standard campaign sanity check — a healthy campaign's references should read near 0% — so it is computed under the campaign's own timing, not the screen's prepost contrast, and combined through `farm_uplift` by energy sums, the same path the test turbines take. It is reported *post-screen*: a ruled-out reference is listed and flagged, so the drop stays visible, but is excluded from the combined number and from every reference pool. A healthy prepost campaign reuses the screen's final pass instead of refitting the pool, since the contrast and pools are then identical. Any drop, or a toggle campaign, refits — dropping a reference changes what every survivor reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: stop screening once a drop leaves too few references to vote The "needs at least 3 candidate references" guard was applied only on entry, not per pass, so a three-reference pool that dropped one kept screening the remaining pair. Two references are always equidistant from their own midpoint, so the rule is degenerate there: it flagged one arbitrarily and then hit the majority guard, crashing the fixture with a farm-wide-problem error on a pool whose only real fault had already been found. Found by the fixture rather than by a unit test — the toy estimator happened to put the surviving pair inside the floor, real data did not. The test that pins it asserts no pass ever runs on fewer than three turbines, which is the invariant rather than the symptom. Two existing tests encoded the old behaviour (expecting a second pass on a three-reference pool) and now use a five-reference pool, which still has a majority after one drop and so genuinely re-screens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: prepost-only screen, floor calibrated on clean pools The screen no longer runs for toggle campaigns. Toggle's contrast is between on- and off-blocks within the test, so a reference change predating the test is common-mode and cancels — which is why the fault bit -0.69 to -1.14 pp in prepost against -0.31 to -0.52 pp in toggle. The screen cannot see one there in either construction: the on/off contrast is blind to it by design, and splitting the test in half leaves ~3 seasonal months a side whose noise swamps the signal (the five-reference injected arm read *below* its own clean baseline). Ruling out a good reference costs more than leaving a mild bad one in, so it does not run. `power_model` is nonetheless still exposed in toggle, and that is left open deliberately: its fit reaches into the pre-campaign baseline under `adaptive_time_decay`, which straddles a reference change at the campaign boundary, reintroducing a bias the toggle contrast would otherwise cancel. That is a fit-window exposure, not a toggle-design one, and closing it re-opens the `toggle_campaign_only` knob Issue 16 pruned — its own decision, documented in the design note to revisit. `screen_floor` is now 0.025, calibrated rather than chosen. Clean placebo pools spread up to 1.19 pp with nothing injected, so 2.5 pp leaves about a factor of two before a healthy reference is at risk. The previous placeholder of 1 pp fired on healthy turbines in three separate places, and on the clean fixture cell that false positive cost 0.88 pp of error — the screen made a good campaign worse. At 2.5 pp the clean cells are unchanged to five decimal places. The five-reference arm now injects -8% rather than -4%: two references moving together drag the pool median toward themselves and roughly halve every deviation the screen sees (3.48 pp for one bad, 1.81 pp for two), so it takes about double the magnitude to clear the same floor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: carry the Greenbyte active-power minimum; add the screen road-test driver `power_model` requires the `active_power_min` role, and the Greenbyte adapter did not carry it, so it could not run on Kelmarsh or Penmanshiel at all. The export publishes `Power, Minimum (kW)` — the adapter was simply dropping it — so this is a column added, not a signal invented. The synthetic zip the tests build now carries it too, and a test asserts the minimum never exceeds the mean. `benchmarking.campaigns.screen_roadtest` runs the shipped configuration with the screen on over placebo campaigns on four reference sets: Hill of Towie whole farm, Hill of Towie west (T01-T15), Kelmarsh and Penmanshiel. Nothing is injected, so every reference the screen rules out is either a genuine find or a false positive, and both are worth seeing before this ships. It reports the test headline, the post-screen reference overall uplift and which references were ruled out. ERA5 comes from the farm's own mean coordinates, so the driver is not Hill of Towie specific. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: do not screen a campaign too short to screen reliably Running the frozen benchmark sweep with the screen on removed references — which it should not have, since nothing is injected there. The removals were not marginal: one read T04 at -5.902%, 8.02 pp from its pool median against a 2.5 pp floor, with T01 at +2.12% and T03 at +4.24%. A 10 pp spread across three references on a placebo. The cause is campaign length, not a bad reference. That case had n_up ~ 4167 records, about 29 days. Measured on clean Hill of Towie pools across four treatment-start windows, the worst deviation by campaign length is: 1 month 2.87 pp 3 months 1.51 pp 2 months 2.47 pp 6 months 1.43 pp 12 months 0.95 pp At 1-2 months the clean spread reaches the floor, so no floor separates a bad reference from a good one; from 3 months it sits at 1.51 pp or below, 1.65x under the floor. So the fix is a minimum-data gate rather than a different floor: `screen_min_campaign_days` defaults to 90, and a shorter campaign is not screened and says so. Counted from records rather than calendar span — a campaign with gaps carries less data than its span suggests, and it is the data the estimate actually has that decides how noisy it is. The screen also now logs the estimate, the deviation and the floor it acted on, because the first sweep gave no way to tell a real find from a false positive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: record the screen's calibration and limits as CF13 Covers what the runs established: T17 is a genuine Hill of Towie bad reference (+4.67% against a 20-reference pool whose median is +0.26%, reproduced on an unrelated pool, and visible directly in the SCADA as a 0.735 -> 0.804 step in its power ratio to its neighbours across 2017/2018); the campaign-length constraint that makes short campaigns unscreenable at any floor; the clean-spread calibration of the floor and the 0.88 pp cost of the false positive the earlier placeholder caused; the halving of signal when two references go bad together; why toggle is not screened and where power_model stays exposed there anyway; the runtime cost; and the three bugs real runs caught that the unit tests did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: fix LightGBM feature names; make the reference report skippable in sweeps The road test crashed on Kelmarsh and Penmanshiel with "Do not support special JSON characters in feature name". Their source tags carry a comma ("Power, Minimum (kW)") and a degree sign ("Nacelle position (°)"), so `power_model` could not fit on either farm — a portability bug that only a second and third data source could surface. Fixed at the fit boundary rather than in the adapters: `model_safe_features` renames columns positionally for fit and predict, which sidesteps the whole class of rejected characters instead of guessing which ones this LightGBM objects to. Nothing is lost — importances come back positionally and the diagnostics carry the original tags — and the design's rule that source-native names survive verbatim into diagnostics is preserved. `report_reference_uplifts` (default True) turns off the per-reference uplift report. It costs a model fit per reference on every estimate, which is what made the frozen benchmark sweep roughly 12x slower, and a sweep scores estimators rather than reporting campaigns. All four sweep drivers now pass False. It is a report, not an input: a test asserts turning it off does not move the headline, and that the screen still runs and is still reported. The toy screen tests set `screen_min_campaign_days=0`: their campaigns are a fortnight long and they exist to exercise the outlier rule, not the minimum-data gate, which has its own tests at realistic campaign lengths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: raise the screening gate to 150 days, set by the benchmark sweep At 90 days the sweep still produced false positives on 3-month campaigns: a reference read -2.9%, about 3.1 pp from its pool median against a 2.5 pp floor, and ruling it out made the benchmark *worse* — prepost overall score +0.103 pp and spread +0.126 pp at 3 months. That is the failure the whole design is meant to avoid, since removing a good reference costs more than leaving a mild bad one in. The sweep isolates the driver cleanly. Every other cell was exactly unchanged: 1- and 2-month campaigns (the gate already skipped them), 6- and 12-month campaigns (the screen runs and correctly finds nothing), and every toggle cell (the screen does not run there at all). Only 3 months moved. The same three- reference pool is clean at 6 and 12 months, so campaign length is the driver rather than pool size. 150 days sits clear of both boundaries: a 3-month campaign (~90 days of upgraded data) is not screened, a 6-month one (~182 days) is. My own four-window calibration had put 3 months at 1.51 pp worst; the sweep samples far more windows and found 2.9-3.25 pp, so the sweep is the better calibration and this takes its number rather than mine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: stop the reference-uplift pass recursing through its own clones Adding `report_reference_uplifts` in 4accc6b changed the guard on the reference pass from `self.reference_screen` to `self.report_reference_uplifts`. The clones that estimate each candidate reference set `reference_screen=False`, which had been stopping the recursion by accident; they did not set the new flag. So every clone launched its own full reference pass, and its clones launched theirs, down to a pool of one. The work is combinatorial rather than linear, and the road test showed it directly: the same 21-turbine case that took 39 minutes before the change was still running after 9 hours, logging a method named `power_model_screen_reference_reference_...` with seventeen nested suffixes. The benchmark sweep was unaffected because it sets the flag to False. Both clones now come from one `_pass_clone` helper that turns off both passes, so the two cannot drift apart again, and a test asserts the invariant on each clone rather than on the symptom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: record the road test and benchmark verification in CF13 The road test covers four reference sets on placebo campaigns. The floor was calibrated on Hill of Towie alone, and it fires on the one farm with a genuinely bad turbine (T17) while staying silent on three that do not — including Kelmarsh and Penmanshiel, which it had never seen. Reference overall uplift reads -0.26% to +0.39% throughout, so the sanity check behaves everywhere. hot_west is the control: it excludes T17, screens nobody, and gives essentially the same estimate as the whole farm. The frozen benchmarks are unchanged: zero references ruled out, zero MOVED verdicts, every delta zero in both modes at every campaign length. Also records the fourth bug the runs caught — the reference-pass recursion — and why only a large-pool run with reporting on could have found it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: make the screen's remediation configurable for the A/B Three options for a ruled-out reference: "drop" removes it from the features entirely, "direction" keeps its north-calibrated direction (which a performance change does not corrupt) so its wake geometry survives, and "direction_waking" (the default) also gives it a waking boolean in place of the power it loses. Which is best is an empirical question, and F13's lesson is that a physically-compelling feature argument can still lose to the benchmark — so the choice is made against the fixture rather than asserted. A test pins that remediation changes only what happens after detection, never what is detected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: default the screen's remediation to direction-only, per the A/B Movement from each pool's clean cell on the prepost fixture arms [pp], truth zero: remediation +5% -4% 2x-8% mean worst no screen 1.185 1.137 3.198 1.840 3.198 drop 0.376 0.276 1.332 0.661 1.332 direction 0.314 0.288 1.272 0.625 1.272 direction+waking 0.197 0.398 1.408 0.668 1.408 Screening cuts mean error from 1.84 pp to 0.63 pp and recovers up to 3.2 pp on the two-bad case, so the screen clearly earns its place. Keeping a ruled-out reference's direction beats dropping it outright on both mean and worst case, which is the point of power-free rather than removal: its wake geometry is not corrupted by a performance change. The waking boolean does not earn its place. It has the single best result but is worst on the other two arms and has the worst spread. The margin is thin — 0.04 pp separates the three means across three arms on one farm — so this is a weak preference, taken partly because direction-only is also the simpler mechanism. F13's lesson repeating. The other two remediations stay available. This cannot move the frozen benchmarks: they screen nobody, and every arm leaves the clean cells byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: keep direction + waking as the only remediation, prune the alternatives The A/B put direction-only marginally ahead, but the three remediations sit within 0.04 pp of each other (0.625 / 0.661 / 0.668 mean movement) across three arms on one farm and one test turbine. That is not evidence the waking boolean costs anything — it is evidence the fixture cannot separate the three. Against that, waking carries information the fixture does not exercise: a reference that stops producing entirely still tells the model it has stopped waking its neighbours, which neither dropping the turbine nor direction-only can express once the power channel is gone. So it stays, and the two alternatives are pruned rather than left as dead knobs — `screen_remediation` and the `include_waking` parameter are gone, and a ruled-out reference always keeps its direction and gains a waking boolean. The A/B numbers stay recorded in CF13 and the design note, since the reason for keeping a marginally-behind option is exactly the thing a later reader will want. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: address the PR 140 review All nine comments were valid; two were worse than the diff could show. **Power minimum now follows the mean** (comment 1). `active_power_min` is an unconditional model feature, and `ReferenceCpChange` moved the mean while leaving it behind — an impossible channel mismatch the screen could key on instead of the Cp shift, which would have made the detection and A/B results measurements of an artefact. It was documented as a known limitation without following through on what it meant for the conclusions. Positive minima now scale with the mean; negative ones are left alone, since a negative minimum is parasitic draw rather than production and a Cp change does not scale it. **power_free no longer removes availability** (comment 2). It is not a power channel, and the documented contract says only power channels go. Moot under the shipped `availability_feature=False`, but wrong for anyone who enables it. **The gate now uses the worst-covered candidate** (comment 3). It counted the frame's timestamp index, so a 150-day frame in which one candidate had a fortnight of campaign data passed the gate and then estimated that candidate in exactly the short-data regime the gate exists to avoid. **Pass clones write no diagnostics** (comment 4). `out_dir=None` does not suppress diagnostics — it makes a temp directory per run, and there are O(N) runs per estimate. The machine had accumulated 5845 `/tmp/power_model_*` directories totalling 151 MB. `write_diagnostics=False` requires `conditions=()`, since the conditional step writes into the run directory. **The screen calibration is prepost only** (comment 5); its toggle default would have recorded nothing now that toggle is not screened. **The run config records the screen settings** (comment 6), so a run is reproducible in whether screening was on and at what floor and gate. **CF13 corrected** (comments 7-9): the heading claimed 3+ months when the gate is 150 days and a 20-reference pool when it is 19; the 90-day gate was left standing in one section and contradicted in another; and the reference-reporting decision was recorded as unresolved after this PR resolved it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: record that the power-minimum fix leaves the conclusions intact Re-ran the fixture with the minimum scaling consistently. The screened results are identical to three decimal places (+0.197 / +0.398 / +1.408 pp) and the same references are detected; the unscreened bite strengthens about 4% now that the minimum carries the change too. There is a structural reason the screened numbers cannot move: a ruled-out reference loses its power columns, mean and minimum alike, so the consistency of a channel no longer in the feature matrix cannot matter. The artefact could only ever have affected detection, and detection is unchanged — so the R3 results stand on evidence rather than on the assertion that the artefact was harmless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: state the benchmark result precisely rather than rounding it to zero The earlier claim that "every delta is zero" was true of the overall cells, which were checked, but was written as though it covered every cell. It does not: the largest movement anywhere is 0.013 pp in the degenerate 2-month wind-speed conditional cell, whose own score is 14.5 pp. That residual is deterministic (identical across two sweeps), predates the PR-140 review fixes, and is not the positional feature renaming — checked directly, which gives bit-identical LightGBM predictions. It is consistent with the known thread-order reproduction floor. The headline P50 cells are exactly unchanged; the degenerate conditional tail is not quite, and the finding now says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqubHpec4LhzD39d2QUGdc * R3: survive a reference the screen cannot estimate, and mark R3 done Two passes ran a sub-estimate per reference with nothing catching it, so one candidate too degenerate to fit took the whole campaign down -- including reference_uplifts, which runs after the headline is already computed and is documented as never moving it. The screen now reads an unestimatable candidate as NaN, which ranks worst and rules it out; the report records NaN and carries on. worst_outlier gave a non-finite estimate an infinite deviation, which cleared every floor including an infinite one, so screen_calibration's observe-only pass could drop a reference and run a second pass, mixing post-drop deviations into the clean spread the 2.5 pp floor is calibrated from. A non-finite floor now rules nobody out; a NaN estimate is still ruled out at any real floor. Also: reference_uplifts returns its documented columns when it has no rows, screening_timing reads the campaign's own rows like its sibling helpers, summarise drops include_groups (pandas 2.2, against a 2.0 floor), and the two guards _validate_model_config now reaches first are removed rather than left dead behind it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CtbcQA3mRMixnFXVfuFVE * CI: cache the Zenodo datapack, and survive Zenodo dropping a 1.4 GB download The test job pulls several 1.3-1.5 GB Hill of Towie year zips from Zenodo on every run, and Zenodo drops them: the last four red runs were IncompleteRead, 504 Gateway Timeout, and a BadZipFile that turned out to be a cascade of the first two. Cache the download directory, keyed on the record id, so it stays off the wire after the first green run. Four defects behind the flakiness: - HTTPError was not retryable, so a 504 aborted at the first attempt; 429 and 5xx now retry, 4xx still fails fast. - A stream ending short was accepted, so a truncated zip could be cached. Bytes on disk are now compared against Zenodo's recorded size. - ensure_hot_data_files tested is_file() alone, so a 545 MB partial 2017.zip counted as cached and reached ZipFile -- the BadZipFile. A file shorter than its recorded size now counts as missing and resumes via Range. - raise_for_status() sat outside `with result`, so a 4xx/5xx leaked the streamed socket to GC and tripped filterwarnings=error in an unrelated test as an unraisable ssl.SSLSocket ResourceWarning. Attempts go 5 -> 10 with the backoff capped at 30s, since resume does make progress against Zenodo, just slowly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CtbcQA3mRMixnFXVfuFVE * R3: address the second Copilot review Screening: a pool with more references the screen cannot estimate than the majority allowance now raises instead of dropping one alphabetically and reporting success with the rest still in the pool. Checked after the drop decision, so an infinite floor still observes without acting. Download cache: a file longer than Zenodo records it is corrupt, not complete -- a resume that re-appended bytes leaves one. The size check is now exact and an oversized file is discarded inside the retry loop, since resuming it would send a Range past the end and earn a 4xx not worth retrying. Screen gate: _campaign_days counted every row carrying a power reading, while a screening fit also drops unavailable and stuck rows. It now counts through the same NormalOperationFilter _select_rows uses, so a turbine with a year of readings but a fortnight of available ones is judged on the fortnight. This moved the wind-speed column guard ahead of the screen -- the gate reads that column now, so a missing one surfaced as a bare KeyError rather than the configuration error it is. Road test: every farm is still attempted and its results still written, but a run with a failed farm now raises rather than exiting clean on a partial table that reads like a completed four-farm validation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CtbcQA3mRMixnFXVfuFVE --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The committed baseline was recorded on 2026-09-03 at cbe3297, before the reference-validity screen merged, so the file asserted a provenance that no longer described the shipped code. Re-run on v1 with a clean tree so the stamp names a commit a reader can check out. Both modes are neutral against the old baseline: prepost 0 better / 0 worse of 1029 cells and toggle 0 of 1001, on spread, score and |bias| alike. The largest delta in either mean-over-profiles table is 0.001 pp. R3's availability-aware screen gate therefore changes no benchmark cell -- the same 56 long-campaign prepost cells screen as before (the gate skips exactly the 84 one-, two- and three-month cells) and they still rule out no reference. The diff is large and the numbers are not: 3790 of 6300 cells move, none by more than 0.005338 pp, against a 0.1 pp materiality band. That is same-machine LightGBM reduction-order noise, and re-baking it is the price of a stamp that says when the benchmark was last actually reproduced. Sweep: 2h 06m for 280 estimates; prepost 1h 22m, toggle 44m. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CtbcQA3mRMixnFXVfuFVE
* power_model: take the reference pool from the campaign, not the frame build_reference_features and reference_mean_wind_speed derived the pool as "every turbine present except the test turbine". That agreed with the campaign only because context.select() runs first; select(also=...) would have enrolled co-analysed test turbines as references, and check_reference_only only bars the one test_wtg named. Both now take an explicit references pool. PowerModelMethod._candidate_references derives it from context.references_among(), so n_refs is the declared pool size rather than nunique() - 1. Behaviour is unchanged for every current caller. Also renames a local in estimate() that shadowed the pool with the reference-uplifts frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CtbcQA3mRMixnFXVfuFVE * R4 stage 1: a probe for which missing-data shapes crash and which stay silent An information-gathering driver, not a fixture: 19 arms over the field failure modes (whole-farm outage, whole-turbine outage, single-signal outage, ERA5 columns lost, ERA5 holes), each in the two shapes that take different code paths -- a column absent from the frame, and a column present but NaN. Runs the real campaign path so the shared northing step sees the fault too, and attributes a failure to the deepest frame in this repo's own code, which separates a northing failure from a power_model one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CtbcQA3mRMixnFXVfuFVE * Report missing data as the missing column, not as somebody else's problem The R4 probe found five missing-column failures, none of which named its cause where the analyst reads it. - era5_direction and northing discovery raised bare pandas KeyErrors; both now raise naming the column and what it is for. - The reference screen reported "a farm-wide problem" when every candidate was unestimatable for one reason: a column that was not there. The cause was logged as a warning while the misleading verdict was what stopped the run. It now records why each candidate failed and appends those causes to its own verdict, chained to the original error. - A missing conditional matching column took the headline down with it, after the fits had already paid for it. Following the era5_exclude idiom, the untouched default matching_vars is now skip-if-missing (warn, drop the conditional breakdown, keep the P50) while an explicitly-set one keeps the strict guard -- and the check runs before the fits, so that raise is immediate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CtbcQA3mRMixnFXVfuFVE * Say when the reference pool shrinks, and wrap up R4 The pool is derived once from the campaign's candidate references intersected with the turbines the frame carries, and the screen and the reference-uplift report now take that pool instead of the raw declared list. A campaign that declares a reference the delivery never contained previously reached the stuck-sensor filter with an empty frame and died as "IndexError: iloc cannot enlarge its target object"; it is now dropped with a warning naming it and the pool size the estimate actually ran on. The stage-1 probe never saw this because its context is derived from the frame, so declared and present always agreed there. Also threads a seeds parameter through the probe, which is what measured the 0.127 pp estimator-noise floor CF14 reports. Findings in CF14. R4 is marked done with two corrections stated as corrections: it was not power_model-internal (two of five failures were in the shared northing step), and signal discovery was measured and deliberately not built -- every NaN-shaped outage already returned a number, so adapting would have turned honest raises into silent estimates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CtbcQA3mRMixnFXVfuFVE * Act on Copilot's review of PR 141: three real defects, all confirmed - The conditional guard sat after screen_references, so an explicitly-named missing matching column was only caught once the screen's clone fits had already run. It is now decided before the screen, judged on the raw ERA5 columns: era5_feature_frame passes every raw column through, and _validate_model_config already refuses an era5_exclude that would remove a matching var, so the two are equivalent. - The pool-shrinkage warning could never fire. context_for intersects the declared candidate references with the turbines present in the frame, so a turbine the delivery lacks never reaches mi.context.candidate_references. The announcement moves to context_for, where the intersection happens and the declared pool is still known. - _campaign_days iterated the declared references and returns 0.0 for one with no rows, so any missing declared reference silently disabled screening even with a full pool of delivered ones. The gate now judges the same pool the rest of the estimate uses. The last two are latent on the runner path today for the same reason the second one describes, but they are real and the fixes are small. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CtbcQA3mRMixnFXVfuFVE --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
docs/superpowers/ is gitignored by convention; force-added here purely so this spec (with the reference-stability and reference-selection review updates) can be pulled on another machine. To be git rm'd once no longer needed there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* W1a phase 1: the truth-free campaign core
Extract estimate_campaign() into benchmarking/campaigns/run.py: a campaign
run that reads no ground truth, so real data with no original_df beside it
can be run at all. CampaignRunner becomes a thin truth-adding layer over it,
with its public behaviour unchanged.
- run.py: visible_mask/visible_scada re-cut from CampaignRunner._visible_dataset
to operate on frames, so the core can clip and north and the runner can clip
the ground-truth original separately. estimate_campaign() returns a
CampaignReport carrying per_turbine, farm, farm_uplifts, reference_stability
and conditional -- none of them with a truth column.
- report.py splits: write_report() is the analyst-facing report, adding a
reference_stability table (each candidate reference estimated as if it were a
test turbine) and estimate-only conditional output. write_campaign_report()
is the benchmark overlay on top, unchanged in what it writes.
- scoring.py splits score_one into the estimate plus score_output(), so a
campaign can estimate once on the truth-free path and score that same output.
- plots.py: plot_conditional_uplift draws estimates alone when the frame has no
mean_truth column; conditional_estimates() shapes an output without truth.
test_runner_pin.py pins the runner's numbers on a campaign with an injected
upgrade before the refactor moves anything -- the existing runner tests are
placebo tests where every number is zero. test_runner.py and
test_placebo_end_to_end.py pass untouched.
poe all-fast green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LMXdmmtp1kyqhHsCnQA2w
* W1a scope: record the truth-free core and where wind-up lives
Two corrections to the W1a issue, both describing code that has now landed.
The scope told the next reader to build wind-up in benchmarking/baselines
"like every v1 method". The spec's decision 1 rejects that: at method level
wind-up would be a no-op relabel of power_model, since the R3 screen is
already inside PowerModelMethod and R1 northing is already applied farm-wide.
Northing runs once for the whole farm and cannot sit inside a per-turbine
method without misrepresenting its scope, so wind-up is campaign-level.
The scope also did not mention the truth-free core, which is the bulk of the
work and the reason W3's isolation is structural rather than promised.
Still deferred to the phases that make them true: the declaration decisions,
the C8 boundary, the deferred distance-bounded reference selection, the new
done-when clause, and the W3 isolation mechanics.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LMXdmmtp1kyqhHsCnQA2w
* W1a phase 2: declare a campaign in YAML and run it
An analyst describes a campaign in one YAML file of campaign facts and runs
it, without writing Python.
- loader.py: YAML plus a turbines sidecar into a CampaignSpec. Named column
schemas (hill_of_towie), the northing tri-state, and a tagged timing block
so a third mode is additive. Timestamps are coerced here rather than by
PyYAML: tz-aware is converted to UTC, naive is read as UTC, and the resolved
values are echoed into the run output so a mis-declared timezone is visible.
Reanalysis is not declared -- it is self-served from the turbines file's
centroid over a window rounded out to whole calendar years, so campaigns on
one site share a cache entry.
- composed.py: wind_up_method() is the accepted power_model defaults under one
name, and run_declaration() runs a declaration end to end onto the truth-free
report. The module name is a placeholder; its docstring says so.
- __main__.py: python -m benchmarking.campaigns run campaign.yaml --out DIR
test_composed.py compares wind-up's configuration field by field against the
power_model carried_forward_methods builds, so a default drifting on either
side is caught. test_declaration_end_to_end.py runs the whole path in both
modes on a synthetic farm and asserts the report carries no truth column.
Verified on real Hill of Towie SCADA through the CLI in both modes: reanalysis
self-served from the centroid, four northing changepoints discovered across
nine turbines, the R3 screen ruling out T10 on the prepost arm, and no truth
column anywhere in the output tree.
poe all-fast green: 1425 passed, 98 deselected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LMXdmmtp1kyqhHsCnQA2w
* Add C9: test turbines as wake contributors (power-free, not absent)
A turbine that is not a valid reference is currently dropped from the
estimate's frame entirely, so its wake is invisible. power_model already has
the mechanism -- a power_free reference keeps direction and a waking boolean
but contributes no power -- but it is reachable only through R3's screen, and
only for turbines already in the candidate pool.
Two gates: CampaignContext.select() keeps test_wtg, candidate_references and
also only, and PowerModelMethod never passes also; _checked_power_free raises
on a name that is not already in refs.
It matters most where the campaign is largest: all 21 Hill of Towie turbines
were upgraded by AeroUp, so each estimate would keep only the hand-declared
references and none of the wake neighbours.
Ordered with or just after C8, whose per-turbine timeline makes the role
time-ranged rather than whole-turbine, and before W1b/W2, since it adds a
field to the CampaignContext seam W2 publishes. Carries an open design-note
§3 question to settle on C5's fixture: the waking boolean is thresholded on
power, which is very likely treatment-invariant for a Cp change and is not
for wake steering.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LMXdmmtp1kyqhHsCnQA2w
* W1a phase 3: the analyst handover and the documentation under test
The machinery for a W3 dry run: build a campaign directory an analyst can be
given, holding everything they see and nothing else.
- handover.py: writes <root>/analyst/ (brief, a blank campaign.yaml template,
scada.parquet, turbines.csv, docs/) and <root>/key/ground_truth.json. Only
synthetic_df reaches analyst/; original_df and run_metadata carry the answer
and stay behind. Two tests carry that guarantee: the answer key is outside
analyst/, and analyst/ holds no original data and no run metadata.
- placebo.py: placebo_instance() draws the upgraded turbines and the treatment
start from a seed, so a handover does not identify its own campaign by
matching the checked-in defaults. Its window stays before the site's real
blade-upgrade installs and it never upgrades the site's known bad turbine,
so the truth stays 0 by construction. placebo_campaign() and
placebo_analysis_period() take a campaign_start to make that possible.
- docs/running-a-campaign.md: the analyst-facing documentation, and the thing
W3 actually measures. Covers the declaration, the run, and how to read the
outputs -- leading on reference_stability.csv, since that is the campaign
checking its own references. Its "known limits" section names the flat
timing model (C8) and dropped wake neighbours (C9).
The campaign.yaml template is deliberately blank: a populated one would answer
the brief before the analyst starts.
poe lint clean; 326 campaign tests pass, poe test-fast green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LMXdmmtp1kyqhHsCnQA2w
* W3 dry runs: record CF15, fix the three defects they found
Two handovers on real Hill of Towie SCADA were given to fresh agents holding
only the analyst directory. The placebo instance was read correctly ("nothing
at all -- I cannot distinguish this from zero"); the injected instance
recovered the magnitude (+1 to +2.5% against a true +2.06%) but read a
wind-speed-dependent Cp gain as a flat one.
Fixed, all three found by being used:
- The reanalysis centroid moved with the turbine roles, so dropping a
reference shifted it, changed the ERA5 cache key and silently changed a
top-5 model feature -- during the very reference-set sensitivity run both
analysts relied on. It is now taken over the whole turbines file and rounded
to 2 dp, so a site keeps one cache entry and the roles cannot perturb it.
- placebo_instance drew 4-6 upgraded turbines regardless of farm size, which
on a 9-turbine slice left two references after screening. The draw now
scales with the farm and always leaves a pool the screen can judge.
- running-a-campaign.md said `name` names the output subdirectory, which is
false when --out is given, and located reanalysis at the centre of the
campaign's turbines rather than the site's. It also now documents the
reference-set sensitivity re-run, which is what actually decided both
assessments and was suggested nowhere.
Recorded rather than fixed: CF15 carries the scores, the isolation holes
(CLAUDE.md is auto-injected into any agent whose cwd is the checkout, and
concurrent runs see each other's processes) and the finding that a placebo on
real SCADA cannot score the fault half. The W3 section carries the remaining
documentation gap list for W2, and W1a's done-when now names the dry run.
poe all-fast green: 1452 passed, 98 deselected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LMXdmmtp1kyqhHsCnQA2w
* Stop the handover template contradicting the documentation
The campaign.yaml template still said `name` "names the output subdirectory"
after the doc was corrected to say it does not once --out is given. A dry-run
analyst caught the contradiction from reading alone, before running anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LMXdmmtp1kyqhHsCnQA2w
* Judge scale from one run, and keep the instance draw off the bad window
Two corrections from the dry runs.
The documentation told the analyst that re-running with a different reference
set was "the single most useful check you can make". That codifies the manual
re-work v1 exists to remove: one run should answer the question. It now judges
scale from reference_stability.csv, which comes from the same single run and
is the noise yardstick, and says that wanting to re-run is a limitation to
report rather than a procedure to follow.
placebo_instance drew its treatment start from 2018 or 2019, and a 2019 draw
puts the treated period into 2020 on a 2019-only baseline -- the one window
that reads far worse than the rest. It now draws 2018 only, so an instance
lands on a window the method is known to handle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LMXdmmtp1kyqhHsCnQA2w
* Draw test turbines with geographic spacing, not at random
An unconstrained draw clustered the test turbines: on the 21-turbine instance
it picked T02/T04/T05 as a mutual triangle and T13/T14 as an adjacent pair,
leaving T05 with no reference among its two nearest neighbours at all. A real
campaign is designed so every test turbine has nearby references to compare
against, so the draw now does the same.
Test turbines are picked one at a time, and each pick retires its own two
nearest neighbours plus any turbine holding the pick among *its* two nearest.
The reverse half matters because nearest is not symmetric: T21's two nearest
are T20 and T19, but T19's are T18 and T20, so picking T19 first would
otherwise leave T21 pickable and adjacent to it.
placebo_instance now takes the coordinates the rule reads, and passes them
through to the campaign instead of the placeholder zeros.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LMXdmmtp1kyqhHsCnQA2w
* Compute distances and bearings on the ellipsoid everywhere, and drop utm
wind_up.geodesy is now the one place distances and bearings come from: one
geographiclib Inverse call per pair (v0 made two), a pairwise matrix for a whole
layout, and local east/north metres measured from the minimum easting and
northing so the maxima give the farm's extent.
The shortcuts it replaces were measurably off on the Hill of Towie layout: the
equirectangular spacing in the placebo draw by up to 0.24% in distance, the
haversine helper in benchmarking/synthetic by 0.35%, its spherical bearing by
0.06 deg. WakeSteering pairs now use the geodesic; no pinned number moved.
v0's calc_distance, calc_bearing and get_distance_and_bearing delegate to it,
with functools.cache in place of the hand-rolled module-global dict, so v0
numbers are unchanged. The cache-timing test clears the cache first, since
calc_bearing now warms it.
The capacity-factor bubble plot projected through UTM, whose grid north is off
true north by the convergence angle: 2.4 m across two Homer test turbines.
It now uses local east/north, keeps its median centring, and utm leaves the
dependencies.
wind_up.layout holds a validated turbine table with its geodesic matrices,
the IEC 61400-12-1 disturbed sector (moved from v0, which re-exports it) and
front-row classification, for the campaign design utility that follows. The
upwind cases of v0's test_get_iec_upwind_turbines are ported to it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147zaQzN7s1THjdDCrwdA1P
* Add a campaign design utility, and design the placebo instances with it
design_campaign chooses test turbines that comply with step 2 of the uplift
validation methodology ("at least three reference turbines"), as rules a set
of test turbines either meets or not:
- among each test turbine's 4 nearest reference-eligible turbines, within
20 of its rotor diameters, at least 3 are not test turbines;
- a test turbine's nearest neighbour is never a test turbine;
- the front-row test count is the nearest whole number to the farm's
front-row share, so neither too many nor too few;
- excluded and reference-only turbines are never tested.
It takes the most test turbines any compliant design allows, then walks the
test priority, keeping each turbine for which a compliant design of that size
still exists (an exact scipy milp look-ahead). Unlisted candidates follow in a
seeded random order. check_design judges any set, however it was chosen, and
write_design writes a per-test-turbine compliance table, every turbine's role
and outcome, a declaration roles block, a summary and two maps.
Brute force over every subset of small random layouts confirms the maximum is
exact and the walk returns the first compliant set in priority order.
placebo_instance now designs with it at the maximum count, replacing the
spacing draw added in f373fe5, and keeps T17 reference-only. On the real Hill
of Towie layout that is 10 of 21 test turbines, 7 of them front row against a
fair share of 6.67. The clustered draw of the first dry runs is reported
non-compliant. test_handover's fixture was missing the coords argument that
commit made required; it now passes them.
The declaration loader skips turbines.csv rows without a name, so a design's
layout file can serve as the sidecar. docs/designing-a-campaign.md explains
the inputs, rules and outputs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147zaQzN7s1THjdDCrwdA1P
* Fill unlisted slots with the nearest references a design of that size allows
Maximising the count pushes references outward: the furthest reference was
often 17-20 rotor diameters on Hill of Towie subsets. After the listed test
priority is walked, the design now finds the least reference-distance limit
that still allows its size alongside the turbines kept, and walks the unlisted
candidates, in their seeded order, under that limit. summary.yaml reports it
as reference_limit_d.
It sits after the listed priority, not before it: applied first, the tightest
limit usually leaves a single design (a 5x5 grid went from 8 distinct designs
to 1), so neither priority nor seed would matter. A fully listed priority is
therefore never overridden, and placebo_instance now lists every candidate in
a shuffled order so its randomness is the priority's, as intended.
On Hill of Towie clusters the median furthest reference drops by 1-3 D (12
turbines: 11.8 -> 8.6 D). The whole farm is unchanged: both of its maximum
designs need 11.8 D.
The first version lost every test turbine on one sparse five-turbine subset:
candidate limits were distance / diameter but membership was tested as
distance <= limit * diameter, and one ulp dropped the turbine that set the
limit; the search then fell back to an unverified limit. Both now use one
distance-in-diameters matrix, and the search falls back to the site's own
limit, where a design is known to exist. That subset is a regression test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147zaQzN7s1THjdDCrwdA1P
* Design placebo instances one test turbine below the maximum
At the maximum count Hill of Towie has exactly two compliant designs, differing
only in T01 against T02, so every instance upgraded the same nine turbines and
only the start date varied. One below the maximum there are 60 compliant
designs (464 two below), and ten seeds now draw at least five different test
sets. A farm that supports a single test turbine still gets one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147zaQzN7s1THjdDCrwdA1P
* Record the campaign design evidence (CF16) and close W1a's documentation ends
CF16 records what designing the test turbines found on Hill of Towie: the
reference rule decides the count (7 test turbines under the strict 3-nearest
rule, 10 under the chosen pool rule), the site has only 2 compliant designs at
that maximum against 60 one below it, clusters design down to 4 turbines, and
only a tie-break confined to the unlisted candidates keeps priority meaningful.
The W3 section now says its instances are designed, not drawn, and its gap list
carries the conditional.csv finding, checked against relevel_conditional: the
per-bin levels are scaled by one factor to the headline, so read the class from
the shape across bins. W2 gains the release of the design utility. The running
page points to the new designing page.
The design spec is force-added under docs/superpowers/specs, as W1a's was, with
its departures after implementation listed at the end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147zaQzN7s1THjdDCrwdA1P
* Hand the campaign design to the analyst, and pull the wake-contributor fix into W1a
write_handover takes the design that chose the campaign's upgraded turbines and
writes it to analyst/design/: both maps, the compliance table, the summary and
the roles block, as a real campaign's design documents would arrive. The
priority columns of its turbines table are withheld, because a placebo's
priority is a random shuffle that would read as expected uplift. A design that
does not match the campaign is refused. placebo_design returns the design behind
placebo_instance for the same seed.
W1a gains an item ahead of the prepost dry run: each test turbine's estimate
drops every other test turbine from its frame, losing their waking state. With
designed instances testing 9 of 21 turbines, each estimate loses 8 wake
neighbours. C9's whole-turbine form is pulled forward to fix it; C9 keeps the
time-ranged form and the wake-steering question.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147zaQzN7s1THjdDCrwdA1P
* Keep every turbine with data in each estimate as a wake contributor
A turbine wakes its neighbours whatever its role, so no turbine with data is
dropped from an estimate any more. CampaignContext gains a wake_contributors
role: every turbine in the frame that is neither the test turbine nor one of
its candidate references. select() keeps them, and power_model feeds them to
build_reference_features as wake_only, with the same channels as a screened
reference: availability, north-calibrated direction and a waking boolean,
never power.
The same holds when power_model estimates a reference, for the screen or the
reference-uplift table: the test turbine, and any reference left out of that
estimate's pool, stay in as wake contributors.
Excluded is now a role only, never tested and never offered as a reference.
Its data is no longer dropped by usable_mask, and SyntheticCampaign.generate
keeps every turbine it is given, declared or not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147zaQzN7s1THjdDCrwdA1P
* Address the Copilot review of PR 142
- Require scipy >= 1.9, the first release with scipy.optimize.milp.
- Reject a turbine name given twice in a declaration's turbines file.
- Record a placebo instance's seed on its campaign, so the answer key names it.
- Mark a reference-only test turbine's compliance-table row non-compliant.
- Treat pandas nullable missing values (pd.NA, NaT) as blank layout names.
- Document that rotor_diameter_m needs a value on at least one turbine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Acw68DCtZYU5aEnV5xBugG
* Type the wind-direction sweep as float64 under every numpy
numpy 2.2 (the Python 3.10 resolution) infers np.arange(360.0) as
floating[Any], which failed mypy in the 3.10 lint job; an explicit
dtype gives float64 under numpy 2.2 and 2.4 alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Acw68DCtZYU5aEnV5xBugG
* Add a front-row map to the campaign design, and draw reference-only turbines by outcome
write_design now also saves front_row_map.png: the farm's turbines in east/north metres, green when
front row and purple otherwise, with other farms' turbines grey. The design maps drop the hollow
reference-only marker and its legend entry; a reference-only turbine is drawn like any other
non-test turbine, blue when in use and grey when not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Acw68DCtZYU5aEnV5xBugG
* Shorten the campaign design docstrings and docs, and drop the design spec from the PR
The campaign_design module, ComplianceReport and Layout docstrings no longer
restate the rules or the tables' columns; docs/designing-a-campaign.md carries
the method. The docs take the review's wording for the roles, say the utility
does not suit upgrades with turbine-turbine interactions such as wake steering,
and list the maps without describing their colours. The campaign design spec is
untracked again, as docs/superpowers/ is git-ignored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Acw68DCtZYU5aEnV5xBugG
* Untrack the W1a design spec, as docs/superpowers/ is git-ignored
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Acw68DCtZYU5aEnV5xBugG
* Record C9's whole-turbine form as done in W1a, and narrow C9 to what remains
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Acw68DCtZYU5aEnV5xBugG
* Define the compliance table's columns once, as COMPLIANCE_COLUMNS
The table is built from the constant, and a row whose columns drift from it
raises instead of leaving an empty column. check_design and design_campaign
point to docs/designing-a-campaign.md for the rules and the layout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Acw68DCtZYU5aEnV5xBugG
* Add a runnable Hill of Towie campaign design example, and show its maps in the docs
examples/v1/design_hill_of_towie.py designs Hill of Towie's 21 turbines with T17
reference-only, from a turbines CSV taken from the Hill of Towie open dataset (CC BY
4.0). It writes the design under examples/v1/output/ and refreshes the two maps
docs/designing-a-campaign.md now embeds in place of the worked example's prose.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Acw68DCtZYU5aEnV5xBugG
* Re-record the frozen benchmarks at 85667b3
Every power_model cell outside 6-month prepost reproduces the ec7dca2 recording
within 0.01 pp, most bit-identical, and toggle is bit-identical throughout;
study_toggle_methods_compare moves at most 0.017 pp on its linux file and not at
all on the portable one.
The exception is prepost at 6 months on cp_minus_10pct and cp_plus_3pct, where
bias rises 0.248 and 0.289 pp because T07's screen now rules out T03 at 2.70 and
2.60 pp against its 2.5 pp floor. Re-running those two profiles from a 4ab8259
snapshot reproduces the old numbers exactly, so the move is the wake-contributor
change: screening estimates now keep the test turbine in the frame. CF17 records
the evidence and why the screen is not being tuned against it yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Run the fast tests in parallel, and stop them fighting over the cores
poe test-fast drops from 613 s to about 100 s. It runs under pytest-xdist with
work stealing, which suits a suite whose per-test cost is very uneven, and
coverage now comes from pytest-cov so the workers are measured.
Parallel workers alone bought nothing: each LightGBM fit already took every core,
so the suite was saturated and system time exceeded wall time. The toy fits are
single-threaded now, and _screen_method passes _FAST_PARAMS rather than fitting
the 600-tree default on its 200-day frames. The placebo instance helpers are
cached, since the design solve behind them is the expensive part and the tests
read the same seeds repeatedly.
test_screening_leaves_no_temp_directories globbed the shared temp directory, so
it saw other workers' runs and failed; it now has a temp root of its own.
60 s is not reachable by scheduling: the suite is ~13 CPU-minutes, so twelve
cores floor at ~65 s. Deselecting the heaviest tests saves 4 s, so marking tests
slow would cost coverage for nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Record the two W3 dry runs, and start an analyst feedback log
Both dry runs clear the one-run bar on the full 21-turbine farm: the first
prepost run and a re-run toggle one each said "cannot distinguish this from zero"
against a truth of zero. CF18 records them, including the near-miss where an
analyst declined a post-hoc reference subgroup its own phase 2 then proved wrong,
and two real anemometer faults on T01 and T16 that no run reports.
What the analysts asked wind-up for now goes to docs/v1/analyst_feedback.md as
AF-numbered entries for triage, rather than staying in a transcript. AF1 to AF3
are the three both analysts raised independently: a reference-relative estimate
with a standard error, reference stability resolved by month, and an anemometer
directory mirroring northing.
CF17 records the benchmark move behind the re-recorded baselines, and why the
reference screen is not being tuned against it: a true bad reference and this
false positive sit 0.1 pp apart, and the benchmark's small turbine groups would
over-fit any change made now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Cap the local test workers, and run the full suite in parallel too
poe test-fast asked for every core, which made the machine unusable while the
gate ran. It now asks for four workers through PYTEST_XDIST_AUTO_NUM_WORKERS,
which xdist's own auto hook reads, so the count is one environment variable to
change and nothing in pytest config depends on it: plain pytest, pytest
-p no:xdist and single-file runs are all unaffected.
On a 12-core machine the fast suite is 4m46s serial, 2m12s on four workers,
1m46s on six and about 1m35s on ten. Four leaves two thirds of the machine free
for the cost of 26 seconds over six.
poe test is parallel now as well, since CI spends about 30 minutes in it. It
uses loadfile rather than worksteal: the slow tests carry big frames on
module-scoped fixtures, and keeping a file on one worker loads each of those
once rather than once per worker. Locally the full suite takes 15m05s on four
workers, 1704 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Run CI on pull requests into v1, and give its test job the whole runner
The v1 feature branches PR into v1 rather than main, and the branch filter named
only main, so those PRs ran no checks at all. The one run PR 142 does have came
from it briefly targeting main by accident.
The test step sets PYTEST_XDIST_AUTO_NUM_WORKERS to 4 because the runner is
dedicated to the job, where the local default deliberately is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* remove comment
* Stop one test's warnings being counted by the next
test_wedowind_pitch_angle asserts preprocess_warning_counts == 0 and failed in
CI on 3.10 with 1. result_manager is a process-wide singleton whose
stored_warnings an analysis clears only when it reads them, so a warning left
behind by an earlier test in the same process is counted by the next one. The
assertion therefore depended on what had run before it, which running in
parallel changed.
Reproduced by leaving a single warning in the singleton before the test: it
fails with exactly the CI diff, left 1 against right 0. An autouse fixture now
empties the store before every test, so the counts are the test's own whatever
else ran, in any order and on any worker.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Log the missing working directory from the first human dry run
"Run it" gives the command and never says where from, while the declaration's
data: paths resolve against the working directory. The first human analyst hit
it immediately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Log the timing block's misleading labels, and correct the last entry
The commented `# prepost:` / `# toggle:` lines in the declaration template read
as required keys rather than labels, so the first human analyst nested prepost
and changeover under mode; mode is then a mapping and the error reports its repr
instead of naming the two strings it accepts.
The working-directory entry claimed the data: paths resolve against the working
directory. They resolve against the declaration's own directory (loader.py:114
and 169-175). The gap is real, the reason given for it was not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Downgrade the anemometer ask, on the measured evidence
Alex's call, 2026-09-12: anemometer faults come out of the brief's failure-mode
list and will not be injected into campaigns, because CF11 measured them at
0.21 pp worst case in prepost and exactly zero in toggle. AF3 keeps only the
weaker half of its argument, that the campaign cannot see a fault that is really
in the data.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Add tracking the W3 brief to W2
The brief an analyst is judged against, and the failure-mode menu it names, live
only in a scratch build script, so a dry run is not reproducible from the repo
and edits to the menu are not reviewable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Track the analyst brief beside the handover it goes into
The brief a W3 dry run is judged against lived only in a scratch build script,
so a run was not reproducible from the repo and edits to the menus it offers
were not reviewable. handover.campaign_brief renders it from the campaign:
who was treated, whether prepost or toggle and on what cycle, the period the
SCADA covers, and the two menus.
The fault menu no longer names anemometer gain steps or drift, and campaigns
will not inject them: CF11 priced them at 0.21 pp in prepost and exactly zero in
toggle, so a dry run spent looking for them measures nothing. A test pins that,
since it is a decision rather than an oversight.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Log that a campaign run leaves no log file
The runner configures logging with basicConfig, so the console is the only copy,
while v0's setup_logger writes analysis.log beside the results. The screen's
thresholds and its early stop live only in that output, and when it stops
reference_stability.csv is header-only, so closing the terminal loses the only
account of how the reference pool was judged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Write the campaign run's log into its output directory
The runner configured logging with basicConfig alone, so the console was the
only copy and closing the terminal lost it. run_declaration now also sends the
root logger to <out>/run.log, which covers the command line and any caller,
since that is where the output directory is resolved. A second run in the same
process replaces the handler rather than adding one, so it writes to its own
directory and not also to the first one's.
This matters because stdout is load-bearing here: the reference screen's
thresholds, the size of the pool it judged, and whether it stopped early are
reported nowhere else, and when it stops reference_stability.csv is header-only.
The documentation now says the log is there and why to read it, and says which
directory to run from -- both gaps a human analyst hit today.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Mark the run log and the working directory as answered
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Refuse toggle and layout inputs that quietly corrupt a campaign
From Copilot's review of PR 142.
A toggle declaration with no `start` built a schedule whose origin is the first
timestamp, so a declared baseline year silently became campaign; it is now
required, since a declaration is a different contract from ToggleSchedule, where
the default is deliberate. A period of zero or less was accepted too. Neither
raises downstream, which is worse than the review supposed: measured on
treated_mask, zero treats no row at all and negative inverts the blocks, so both
produce a silently wrong campaign rather than a crash.
A rotor diameter that is not positive and finite was accepted, and distances are
measured in diameters: a negative one inverts the reference limit, so on a
two-turbine layout a neighbour 1.1 km away reads as -13.58 D and passes the 20 D
rule. Infinity puts every turbine out of range instead, and both corrupt the
disturbed-sector calculation behind the front row. Missing diameters are still
filled from the largest known.
CampaignContext.wake_contributors said "the campaign's other changed turbines",
which has been narrower than the contract since 4a40487: context_for keeps every
turbine that is neither the test turbine nor a candidate reference, including
excluded and undeclared ones. Methods decide from that docstring which channels
they may read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Cut a power-free turbine down to its waking boolean
A screened reference or another changed turbine kept its direction and its
availability, losing only power. Direction is not safe to keep: whatever changed
the turbine can move where it points. A yaw realignment does it quietly and wake
steering does it by design, so the counterfactual could read the treatment back
through the neighbour's nacelle position. Availability goes too, since the
waking boolean already says whether it is running.
Turbines that kept their power are untouched: they still contribute direction
and availability as before.
The frozen benchmarks move with this and need re-recording.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Keep accidental ordering out of the answer, and screen once per campaign
The model is not invariant to feature column order, so anything that decides
that order decides part of the answer. Three places left it to chance:
_reference_input laid the wake contributors out starting from the current test
turbine, so the same reference was screened against the same turbines in a
different column order for each one. That is the whole of the spread I measured
on the placebo, where T17 read +2.770% for all nine test turbines before the
wake-contributor change and +3.005% to +3.220% after: the same analysis, shuffled.
Sorted, every test turbine screens a reference identically.
build_reference_features laid its columns out in the order the caller listed its
references, so an estimate depended on the order a declaration happened to name
them in. Sorted, with a test that declaring one campaign two ways gives one
answer.
The test-turbine loops and the design's roles.yaml are sorted too. roles.yaml is
meant to be pasted into a declaration, and it emitted the priority-walk order,
which is how an unordered run order reached an analyst's console in the first
place; a sorted log says how far through a campaign a run is.
With the screen's verdict now identical across test turbines, a campaign shares
one screen_cache and the round-robin runs once rather than once per test turbine
-- on a nine-turbine campaign that is most of the screen's cost. Opt-in, so a
method used outside a campaign is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Name the reanalysis point wherever reanalysis is named
"ERA5" alone does not say which series a run used, and its features were the
only ones in the matrix with no source at all: a feature importance plot listed
wind_speed_10m beside wtc_ActPower_mean @ T04. Reanalysis now carries the point
it was drawn from in the same @-qualified form, so the plot reads
"wind_speed_10m @ ERA5_57.50_-3.25", and the coverage plots and the lag plot
name it too.
Display only: the model's own column names are untouched, so matching_vars and
era5_exclude still name the raw Open-Meteo columns and no number moves. The
importance and catalogue CSVs carry the qualified name, since turbine features
already qualify theirs in the same column.
A campaign passes era5_source_label() of where it fetched; a method used without
one still says "ERA5".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Draw at most nine turbines on the reactive-vs-active figure
One panel per turbine is unreadable on a whole farm: a 21-turbine campaign drew
21 panels. The figure now draws the test turbine and up to eight others, chosen
evenly across the observed power factors rather than alphabetically, so both
extremes are kept -- an unusual reactive signature is what the plot is read for.
Each panel names its power factor, and the title says how many turbines of the
farm are shown. A farm small enough still shows every turbine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Let a declaration leave out the obvious, and echo it back as YAML
references and excluded are optional now. Leave them out and every other turbine
in the turbines file is offered as a reference, with none excluded, which is what
most campaigns want; declaring either narrows it. Only upgraded is required.
That makes echoing the resolved campaign matter more, since a reader now has to
be able to see what their omissions became. resolved_campaign.json becomes
campaign_resolved.yaml: the same content in the same format as the file it
mirrors, shaped like campaign.yaml, with a header saying that defaults are filled
in, timestamps are resolved to UTC, and the reanalysis block is derived rather
than declared.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Add R7: reactive power behaviour change as a failure mode
Reactive import or export can limit a turbine's active-power capacity, by an
amount that depends on the turbine and the farm's electrical design, so a
reactive-power change is a plausible indicator of an active-power performance
change and sometimes a cause of one. Nothing in the generator changes reactive
power today, and while a campaign plots it per run, no method reads it.
Light on detail deliberately; prompted by a human dry run noticing that the test
turbine's reactive cloud on real Hill of Towie SCADA is unlike its neighbours'.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Schedule R7 last of the R-series, between W1b and W2
Reactive power is exploratory rather than a known bite, so it should not hold up
validating the composed method; W1b's criteria are unaffected either way. It
stays ahead of W2 so a release does not ship without knowing the answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Draw the northing error again, on the signal the model is given
northing_error.png is drawn from the raw nacelle position, so it says what the
sensor did, not what the model sees. northed_error.png in 3_feature_eng draws
the same timeline from the north-calibrated column the shared northing step
writes: a residual offset or a step surviving there is a correction that did not
take, which the raw plot cannot distinguish from one that was never applied.
On real Hill of Towie SCADA the pair reads as it should: raw, T01 sits 160 deg
out and T16 steps from -30 to -85 in June 2017; northed, all 21 turbines band
within about 6 deg and move together seasonally.
Skipped when the northed column is absent, so a method run outside a campaign
draws the raw one alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Group the feature histograms by signal
Seventy-one plots in one folder buries the ones there is a single copy of. Each
signal with more than one plot now gets a folder named for it -- every turbine's
copy of a tag together, and a sine and its cosine counted as one signal -- while
a signal with a single plot stays at the top level. On a 21-turbine campaign that
is 12 plots at the root and 6 folders rather than 71 files in a row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Show what the waking feature carries, and who is reduced to it
A power-free turbine contributes one column and the run said nothing about it.
Two plots in 3_feature_eng, beside a waking_fractions.csv:
waking_layout.png maps the farm with each turbine marked by role, so which of
the test turbine's neighbours are power-free is a glance rather than a list of
names. It needs the layout, which the campaign knows and a bare method does not,
so CampaignContext carries coords now -- the turbines.csv an analyst declares,
never an answer, and no estimate reads it.
waking_fractions.png bars how often each turbine was above the waking threshold
in the baseline and in the treated period, labelling any that moved by 10 points
or more. A turbine running differently across the changeover is the case one
boolean column cannot express, and it was previously invisible. On Hill of Towie
nothing moves more than 7 points, which is what a healthy campaign looks like.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Show the campaign's uplifts against the turbines that did not change
The report's tables say how big the answer is; nothing said whether it is
distinguishable. Three plots beside them:
uplift_distributions.png violins the test turbines against the references on one
axis, each point labelled. References are turbines nothing happened to, so their
spread is what the campaign can resolve, and a test turbine inside it is not
distinguishable from zero however large its number.
per_turbine_uplift.png bars every turbine, test turbines first and then
references, split by a divider and coloured by role.
farm_uplift.png bars the headline against the same energy-weighted aggregate over
the references, computed by wind_up.farm.farm_uplift so the two bars are the same
kind of number rather than merely similar ones.
A screened reference is drawn in all three -- an x on the violin, red on the bars
-- but kept out of the spread and the aggregate: the screen ruled it out of the
estimate, so letting it set the yardstick would make a campaign look less
resolvable than it is, while hiding it would conceal a real reading.
Each reference is estimated once per test turbine, so the plots take the median
across them; on Hill of Towie those nine readings now agree to 0.005 pp.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Make a fit reproducible, and say more useful things in the log
LightGBM chooses row-wise or col-wise histograms by timing the machine, so the
same estimate moved with the load on the box and with the thread count. Measured
on the 2.2M-row campaign, estimating T17 as a reference: the two strategies give
5.313129% and 5.314203%, a difference of 0.001074 pp -- which is exactly the
spread seen across that campaign's nine readings of T17, whose values were those
two numbers. Thread count moved it 0.005 pp. With deterministic and
force_row_wise the same fit holds six decimal places across 1, 4 and 12 threads,
at 20-50% more training time. That also removes the dominant term in the
baselines' machine-dependence, though compiler and libm differences remain.
The log changes, from an analyst reading a real run:
- the opening line names the declaration it read and gives absolute paths;
- the reference screen says when it starts, since it is the long silent part and
nothing marked its beginning;
- the feature-gain dump moves to DEBUG, INFO names what the model leant on, and a
WARNING fires when the strongest feature is not a neighbour's active power,
which is what should lead;
- the uplift tables read as percentages and as "measured uplift" rather than
"estimate";
- reference stability collapses to one row per reference with test_wtg ALL, since
every test turbine estimates it identically. A spread above 0.1 pp between them
means their contrasts differ, so that warns and falls back to the full table --
which is what C8's per-turbine changeovers will do.
Every recorded number moves with the fitting change; the benchmarks are being
re-recorded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Shorten the screening message, and claim reuse only when there is a cache
Seen repeating in a real study log it was wordy, and its tail promised the other
test turbines would reuse the verdict -- which is true of a campaign but not of
the benchmark studies, where each case has one test turbine and no cache.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Re-record the frozen benchmarks after the waking, ordering and fitting changes
Covers everything since 85667b3: a power-free turbine cut to its waking boolean,
the sorted feature layout and wake lists, the shared screen verdict, and
deterministic fitting.
Only 6-month prepost moves, which is where the reference screen operates: 57 of
210 cells, mean |bias| 0.071 pp better. The two headline cells go opposite ways
-- cp_plus_3pct improves 0.288 pp, cp_minus_10pct worsens 0.124 pp -- both still
turning on whether T03 is ruled out, now at 2.533 pp against the 2.5 pp floor.
That borderline is the same one CF17 records, and tuning the screen is still
deliberately deferred until the benchmark carries larger turbine groups.
Every other prepost cell and every toggle cell reproduces within 0.015 pp;
toggle is unchanged outright. study_toggle_methods_compare moves at most
0.066 pp on its linux file, inside power_model's reproducibility band, and the
portable file is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Print energy as energy in the logged tables
actual_energy is a sum of mean power over the treated records, so the reference
table printed 3.436161e+07 and left the reader to work out both the unit and the
factor -- the same column an earlier dry run called a 6x trap against the MWh in
the log. With the run's timebase it becomes "treated energy [MWh] 5727", and
n_records prints as a count rather than 52371.0, which the median aggregation had
made a float. Without a timebase the column is dropped rather than printed as a
number whose unit the reader has to guess.
The stored column is untouched: farm_uplift's guard compares it against a rated
power, so its units are load-bearing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Give a whole farm's lines distinct styles, and log the third dry run
Twenty-one series drawn from a ten-colour cycle repeat, which an analyst raised
after reading input_data_coverage.png. Colours and dashes now rotate together
with coprime counts -- 10 x 3 -- so every pair appears before any repeats and 30
series are distinguishable. Applied to the three whole-farm line plots: input
coverage, power factor, and the northing error timelines.
instance-h passes the one-run bar (a third time) and its asks are recorded. AF1
is now raised by three analysts independently: today's farm_uplift.png answered
half of it, and instance-h used that plot to conclude the method reads +0.6-0.7%
on this site for no change at all, then asked for the same number as a column.
AF5 wants each estimate's per-reference gain shares, AF6 a per-turbine data
quality table -- which is how it found T16 curtailed 75% of the time -- and AF7 a
flag when a northing step lands near the changeover.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Keep every turbine's coordinates, and check the names that become directories
A declaration that lists its references explicitly leaves any other named
turbine holding no role, and the spec dropped those turbines' coordinates.
They are not ignored: context_for retains every turbine present in the SCADA
as a wake contributor, so the waking-layout diagnostic was asked to draw
turbines it had no coordinates for and silently left them out. The spec now
carries the whole turbines sidecar, which is what the file is.
The campaign name and the turbine names are used as output directory names --
default_out_dir(name), then out_dir / wtg for each estimate -- with nothing
checking they are single path components. A name carrying a separator scatters
the run's log, resolved declaration and plots outside the advertised root
instead of failing. Both are now refused at load, alongside the other
declaration checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Plot each method's uplift on its own
A campaign report holds a row per method, and carried_forward_methods builds
two or three, but the three campaign-level plots took the frames whole: the
violin pooled every method's test estimates, per_reference_uplifts medianed
each reference across methods, and the farm bar read farm.iloc[0]. Every
placebo run has therefore been drawing one method's headline over everyone
else's spread.
per_method_inputs splits the report into one plottable slice per method, and
each method's three plots are written under a folder of its name. The composed
wind-up run is single-method, so its numbers are unchanged and only the plots
move, into wind-up/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
* Say what a wake contributor actually contributes
The declaration section told analysts that a turbine outside the reference pool
enters each estimate "through whether it was running and where it pointed".
Direction is exactly what build_reference_features takes away from those
turbines: a power-free reference and a wake-only turbine contribute the
thresholded waking boolean and nothing else, because a realignment or wake
steering moves where a treated turbine points and the counterfactual would read
the treatment back. The doc was naming a signal the estimator refuses.
The outputs table also now lists the uplift plots, which land in a folder per
method.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCpUmwPVSzxJUaYbvKZTwa
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Keep the anchoring pass's 30 deg threshold from being clipped to 10 deg _required_step clipped the support-scaled threshold to [min_step_deg, max_transient_step_deg]. anchoring_only raises min_step_deg to 30 while max_transient_step_deg stays 10, and np.clip with inverted bounds returns the upper one -- so pass 1 accepted every 10 deg step against reanalysis while asking for 30. The ceiling is now max(min_step_deg, max_transient_step_deg). TestAnchoringPass pins it: a persistent 20 deg step is not reported under anchoring_only settings (it was), and a 60 deg one still is. The Hill of Towie real-data tests are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Test northing for rotation invariance on real data Rotates every direction input -- turbine yaw and reanalysis alike -- in the Hill of Towie fixture and checks the north tables do not move. Its wind is mostly south-westerly, so +150 deg puts most of the record within 60 deg of north (asserted), making every 360-0 crossing a chance for a wrap bug to show. Covered: a lone turbine against reanalysis, the whole farm, and half the farm. Rotations by multiples of the 30 deg veer sector give identical tables. So does every rotation with veer_sector_deg=None. Other rotations regroup rows across the fixed absolute sector boundaries and can move a changepoint (T16, T11, T01 at 137/223 deg); those are non-strict xfails that document the dependence. There is no 0/360 wrap bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Let north_farm take its own pass-1 settings and repeat the consensus pass anchoring_settings bounds pass 1 separately, so reanalysis can be limited to bulk alignment and changepoint work left to the farm consensus. refinement_passes repeats the consensus pass. Repeating alone reaches a fixed point: a step pass 1 left in one device moves the consensus median, every device's refined table inherits that shift, and a consensus-relative pass cannot see a common-mode error. Before each rebuild, _common_drift takes the per-timestamp median across devices of how far refinement moved each offset from pass 1 and removes it, leaving the shift to pass 1, which is anchored to reanalysis. A step one device carries is outvoted by the median. On a four-device synthetic farm with a constant pass 1, one refinement gives the clean devices a spurious ~3 deg step; two remove it and recover the real one. Defaults are unchanged, and so are the Hill of Towie real-data results. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Let north_farm build the pass-2 consensus from spatial neighbours reference_neighbours maps each device to the devices whose consensus is its pass-2 reference (itself excluded). None -- the default -- keeps the one whole-farm consensus, so the default path is byte-identical and existing results are unchanged. Why: on a large or heterogeneous farm a turbine on the far side shares no wind with a device yet still enters its whole-farm median, and a miscalibrated or noisy neighbour injects spurious changepoints. Northing each device against only its spatial neighbours -- as consensus-anchor methods do -- keeps those out. Each neighbour set forms a consensus by the same quorum rule as the whole farm: a strict majority, floored at min_devices_for_farm_reference. Validated on Hill of Towie: the five-nearest-neighbour reference reproduces every known recalibration within ~1 deg and adds no changepoint to a quiet turbine. Tests: neighbour restriction changes the reference (a clean turbine inherits its neighbours' injected +40 deg step, where the whole farm out-votes it); validation rejects a too-short list and an unknown device. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Make pass 1 a constant anchor; drop the refinement and anchoring knobs north_farm's first pass is now always a constant bulk alignment to reanalysis (one offset per device, no changepoints); pass 2 does all the changepoint work against the farm consensus. This removes the anchoring_settings and refinement_passes parameters and the _common_drift helper and its refinement loop. Validated on real Hill of Towie data (21 turbines, 2017-2020): the same recalibrations are found, on the same turbines and dates, as the previous anchoring_only default -- including the large ones (+170, +98) -- with only sub-degree magnitude differences. A constant anchor is also strictly safer against the reanalysis-driven outage artefact, since a zero- changepoint pass 1 cannot write an excursion into the consensus. The cost is on farms too small for the consensus median to have a quorum, where a single large mover leaks a small spurious step into clean devices; a new test pins that behaviour, and reference_neighbours (kept) is the lever for heterogeneous farms. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address Copilot review: neighbour edge cases and rotation-test rigor - Reject duplicate reference-neighbour IDs. A repeat satisfied the count check but collapsed in the consensus dict, leaving a quorum no set of distinct devices could meet, so the reference came out empty. - Keep a device's pass-1 reanalysis anchor when its neighbour consensus has no finite value, rather than let estimate_north_table overwrite it with a zero-offset table (the whole-farm bail-out only fires when every reference is empty, so a single starved device slipped through). - Rotation invariance: drop the blanket non-strict xfail on sector-misaligned rotations, which hid changepoint regressions behind the expected offset differences. Now assert changepoint count and timing on every rotation -- with a small time tolerance for the sub-day jitter that sector regrouping causes (measured at <=0.1 day on the farm) -- and pin exact offsets only for sector-aligned rotations. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ruff format * Keep the anchor when a device's consensus never overlaps its usable rows The pass-2 guard checked only whether a device's neighbour consensus was finite somewhere, but estimate_north_table's residual is taken over usable & finite(direction) & finite(reference) (see _residual). A device and its neighbours reporting in disjoint periods gives a finite-but-non- overlapping reference, so the residual is still all-NaN and the zero-offset fallback discards the pass-1 anchor. Guard on that exact overlap instead, and add a disjoint-period regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Make nearest-neighbour consensus the default in north_farm north_farm now takes a required `coordinates` parameter (device to (lat, lon)) and norths each device against the circular-median consensus of its `neighbours` nearest turbines (default 4), ranked with the WGS84 geodesic via the new `nearest_neighbours` helper. This keeps a distant or miscalibrated turbine out of a device's reference by default. `coordinates` has no default, so every caller chooses explicitly; pass `coordinates=None` to fall back to the whole-farm consensus. This replaces the previous opt-in `reference_neighbours` mapping -- the layout now drives the neighbour selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix mypy: pass north_farm args explicitly, not via **dict mypy infers the shared `common` dict as dict[str, object] and rejects unpacking it into north_farm's typed parameters. Pass the arguments explicitly instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Pass coordinates=None from the eval harness north_farm call north_farm now requires coordinates; the harness kept its whole-farm behaviour, so pass coordinates=None explicitly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Validate nearest_neighbours inputs and plumb coordinates through the harness Addresses Copilot review on PR 144: - nearest_neighbours now rejects a non-positive k (which would slice the neighbour order wrongly) and any non-finite or out-of-range coordinate (which argsort would still order into an arbitrary "nearest" set). - north_scada now takes a required `coordinates`, passed to north_farm, so the benchmark campaign path uses the layout-driven nearest-neighbour consensus instead of silently opting out. Coordinates are threaded from CampaignSpec.coords (campaigns), the wake-steering case's metadata, and the uncertainty study, via an optional `coords` on the replicate helpers. Callers with no layout, or where no northing runs, pass None explicitly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Temporarily commit R5 northing refinement design spec for cross-machine review Not intended to stay tracked; specs are normally untracked. Committed on request so it can be reviewed on another machine before writing the implementation plan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update 2026-09-21-r5-northing-refinement-design.md * Take a Layout and power in north_farm; stop refusing small farms R5 Stage 1: migrate north_farm from a coordinates mapping to a Layout, add a per-device power input for the coming pass-4 wake nudge, and make the device floor a documented regime boundary rather than a hard error. - Layout tolerates a coordinates-only table (missing rotor diameters take DEFAULT_ROTOR_DIAMETER_M) and gains Layout.from_coordinates. - north_farm neighbours are derived from layout.distance_m. A farm below min_devices_for_farm_reference keeps its reanalysis anchor with a warning instead of raising; a farm too small for its neighbour count degrades to the whole-farm consensus. - north_scada builds the Layout internally, keeping its coordinates parameter. - Remove the now-unused reference-neighbour validation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add pass 4: a wake-nadir absolute northing nudge R5 Stage 2 (algorithm + wiring): a new wind_up.wake_nadir module measures each turbine's residual northing error from where its wake lands on downstream neighbours, and north_farm applies it as an optional fourth pass. - For each directed pair within 10 rotor diameters, the downstream deficit (ratio of bin means, power plus nacelle wind speed where available) versus the upstream turbine's northed direction dips at the geometric nadir; the offset of the measured dip is the upstream turbine's residual, located by a weighted quadratic fit with a curvature/depth/count sigma. - Per turbine, pairs combine by an inverse-variance-weighted circular mean; a turbine with no resolvable dip inherits the circular median of its nearest resolved neighbours, or zero. - north_farm runs pass 4 only when both a layout and power are supplied, adding one absolute correction per turbine on top of its changepoint table; it never alters row validity. Adds an optional wind_speed input for the second signal. Pass 4 is inert in the harness until power is plumbed through (Stage 4); golden tables and the real-HoT validation follow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Aggregate pass-4 wake nadirs by circular median Golden-table validation on real Hill of Towie showed the inverse-variance aggregator made the per-turbine pass-4 corrections spatially incoherent (neighbour disagreement 14-15 deg): the dip sigma is not calibrated, so a sparse, over-fit dip reports a tiny sigma and dominates the weighted mean. Weighting by data volume only halved the spread because the worst pairs are high-volume but biased by terrain deflection, which is not a measurement variance. A circular median of the per-pair view-angle corrections resists that bias and restores smoothness (neighbour spread 6 deg max, 3 deg mean). Drop the sigma path entirely: _locate_dip returns just the dip offset and _Nadir carries (delta, volume). Also guard _deficit_curve against an all-NaN sector bin (empty-slice warning under warnings-as-errors), and add regression tests for the median, the empty bin, and view-angle wrap safety. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Wrap written north-table offsets into [-180, 180) A pass-1 anchor can land just outside the range (e.g. 181.57 deg), which is a valid circular offset but reads oddly and differs from v0's convention. write_north_table_yaml now wraps each offset canonically on the way out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Record golden northing tables for the three open farms Add a study driver that runs the full v1 northing pipeline (passes 1-2 plus the pass-4 wake-nadir nudge) on Hill of Towie, Kelmarsh and Penmanshiel and records each farm's per-turbine, per-changepoint corrections in v0's published YAML layout -- the best-available absolute answer, and the reference future small-N / subset / low-data challenges are scored against. The driver also renders a per-farm wake-nadir bubble plot (positive-quadrant easting/northing via geodesy.local_east_north, fixed +/-10 deg colour scale) and logs plausibility (magnitude, neighbour smoothness). A CI-cheap test guards each recorded table's validity (format, turbine count, window-start first row, sorted timestamps, wrapped offsets). Judging the tables against v0 or physically stays a review job the driver logs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Wire pass 4 into the harness and add the wake-nadir map north_scada now builds power and nacelle wind speed and a layout on discovery and passes them to north_farm, so the pass-4 wake-nadir nudge runs in the harness for the first time; coordinates=None still skips it. north_farm gains an optional nadir_out to report the per-device pass-4 correction without changing its return type, and a new reusable plot_wake_nadir_farm draws that correction on the farm map (one bubble per turbine, fixed +/-10 deg diverging scale), which north_scada writes to out_dir when a layout drove pass 4. Tests: nadir_out populate/empty, the bubble plot, and a harness end-to-end injected-offset recovery on a two-turbine wake-dip fixture. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add pass 3: north small farms against reanalysis with changepoints Below min_devices_for_farm_reference there is no farm consensus to form, so north_farm now norths each device directly against reanalysis with changepoints (pass 3) instead of keeping the flat pass-1 constant anchor; pass 1 is pass 3's no-changepoint special case, and pass 4 still runs on top. against_reanalysis now also raises min_segment to REANALYSIS_MIN_SEGMENT (30 days), not only min_step_deg: reanalysis cannot resolve recalibrations closer together than a month, so a nearer pair is treated as reference wander and merged. On Hill of Towie this cuts spurious single-turbine changepoints against ERA5 from ~106 to 1 while keeping every recoverable published step. Tests: a lone turbine recovers its step, a two-device farm norths each device (the clean one stays clean), the coarser floor suppresses a step below it, and against_reanalysis raises both bounds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add a northing input-degradation study Runs the full north_farm pipeline on the three open farms under a matrix of input degradations -- fewer (geographically contiguous) turbines, less data, and gaps -- and scores each against that farm's golden table, plus a pass-3 reanalysis-config sweep scored against the published table. Every case is wrapped so a crash is recorded as a finding rather than aborting the run, and results stream to a CSV flushed per case. It ends with a compact per-family summary (worst-turbine error by farm, and the pass-3 config recall/spurious curve) so the run is digestible without the raw CSV; `--summary` reprints it from an existing CSV. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Lock in pass-3 regression cases: no over-detection, graceful degradation Retain the Stage-3 challenge cases that bit as regression tests. A real-data slow test norths each Hill of Towie turbine alone (the pass-3 path) against ERA5 and asserts the whole farm resolves to at most a handful of changepoints; without the reanalysis min-segment guard this farm reports ~100. A synthetic test norths a single device over a five-day record and asserts a valid table comes back rather than a raise or a NaN table, locking in graceful degradation at the smallest, shortest input. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Record CF19: the Pass 1' re-anchor is investigated and dropped Resolves the R5 Pass 1' open question. A device's large mid-record step does bias its whole-record pass-1 median anchor, but pass 2 norths each device against the clean farm consensus rather than its own pass-1 offset, so the bias never reaches the answer: on the canonical single-stepper case baseline and Pass 1' both read ~0.13 deg absolute offset error (six seeds), and where the consensus itself is biased (many devices stepping together) Pass 1' is worse, trading the low-noise consensus frame for the noisier reanalysis one. north_farm keeps its four-pass shape with no re-anchor pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Make the golden-table study pass the lint gate An earlier commit left study_wake_nadir_golden.py failing the lint gate: ruff format wanted its title f-string on one line, and mypy flagged the optional greenbyte nacelle-position column passed where a str is required. Narrow the column with an explicit guard and reflow the string. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Record CF20: pass-3 over-detection is fixed by a longer minimum segment Documents why REANALYSIS_MIN_STEP_DEG stays 10 rather than being lowered: sweeping the step floor recovers no additional published changepoints (recall is capped at 13/28, the rest are consensus-only) and only adds spurious ones, while a 30-day reanalysis minimum segment cuts spurious steps from ~106 to 1 on Hill of Towie with recall and offset accuracy unchanged. Closes the R5 Part A floor done-when. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Stop tracking the R5 design spec The spec was committed temporarily for cross-machine review; design specs under docs/superpowers/ are meant to stay untracked (.gitignore already lists the directory), and the durable findings now live in findings_campaigns.md (CF19, CF20). Remove it from version control; the local working copy is kept and ignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Simplify north_farm: drop dead and diagnostic knobs from its signature north_farm exposed three parameters no ordinary caller needs, so a user had to read past them to reach the real inputs. - min_devices_for_farm_reference and neighbours become module constants (MIN_DEVICES_FOR_FARM_REFERENCE, _CONSENSUS_NEIGHBOURS): no caller ever set the first, and only tests set the second. - the nadir_out mutable out-parameter is gone; north_scada's diagnostic bubble plot now recomputes the pass-4 corrections from a no-pass-4 northing via the public wake_nadir_offsets. north_farm now takes eight parameters, all genuine inputs, with settings left as the single expert escape hatch. No behaviour change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address PR 144 review: explicit layouts, one pass-4 run, tidy wake-nadir - No rotor diameter is assumed: drop DEFAULT_ROTOR_DIAMETER_M and Layout.from_coordinates, so a layout without diameters is refused again. north_scada takes a Layout (as north_farm does) instead of coordinates; CampaignSpec carries a Layout in place of coords, read from a turbines sidecar that must now give rotor_diameter_m; GreenbyteFarm records its rotor diameter. - Replicate northing takes NorthingInputs(era5_wd, layout), so asking for northing is also deciding the layout. Five HoT study drivers were passing reanalysis without coordinates and silently got the whole-farm consensus with no pass 4; they now pass the HoT layout. - Pass 4 is exposed as add_wake_nadir, returning the corrections it applied, so north_scada's bubble plot no longer norths the farm twice. - Wake nadir: mask missing wind-speed rows rather than dropping the whole signal on one NaN; drop the unused per-pair volume. - Drop the unused public nearest_neighbours; name the module constant in log messages. - Drop the one-month v0 changepoint test: its two steps ten days apart cannot survive the 30-day reanalysis minimum segment, and the real-data tests cover the behaviour better. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test the default layout-driven northing path on real Hill of Towie data The real-data suite only ran north_farm with layout=None. Hold the nearest-neighbour path to the same published table and outage checks. It currently fails: T15 (2017-05-03, with T05's +35 step), T17 (2019-12-24, with T19's -123 step) and T11 (inside the June 2020 outage) gain spurious steps, a neighbour's own uncorrected step leaking into a small-neighbourhood consensus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Repeat pass 2 until it converges, so a neighbour's step no longer leaks With a layout, pass 2 norths each turbine against the median of its four nearest neighbours. Pass 1 is a single constant, so each neighbour's own steps are still in the signals that consensus is built from, and a median of four moves several degrees when one neighbour steps: the turbine got a spurious step on the same day. On Hill of Towie this gave T15 three steps with T05 and T16, T17 one with T19, and T11 one in the June 2020 outage. Pass 2 now repeats, rebuilding each consensus from the previous round's tables, until no changepoint moves by more than a day and no offset by more than 0.5 deg (at most 10 rounds). A turbine whose neighbours all settled keeps its table, so later rounds touch only the neighbourhoods still settling. The 5-year HoT record converges at round 7; the golden table goes from 30 changepoints (13 not in v0's published table) to 19 (2 not in it), with none of the published ones lost. Kelmarsh and Penmanshiel gain no changepoints. Recorded as CF21. Golden tables regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test the default northing path on two years of real data, pass 4 included The real-data tests ran on a fixture of yaw and ERA5 only, so they could exercise neither pass 4 nor the usable mask a user's run builds from power and availability, and most asserted on the layout=None fallback rather than the default path. The new fixture, northing_farm_inputs.parquet (15 MB, git-lfs), is exactly what the degradation study northes: every Hill of Towie turbine's yaw, power and nacelle wind speed on its yaw_usable rows, and ERA5, for 2017-2020, integer-scaled. make_northing_farm_fixture.py rebuilds it. The old yaw-only fixture is removed. The tests now run north_farm as a user would: the full Layout, power and wind speed. On each of 2017-18 and 2019-20 they hold it to exactly the published recalibrations (no leak into neighbours), no step during the farm outages, and absolute offsets that agree across the window boundary. A representative sample of the study's degradation cases (fewer turbines, short records, both, missing data) is held to its recorded error against the full-data answer; the costly cases are slow. The whole-farm fallback, pass 3 and the rotation-invariance suite move to the new fixture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Refuse physically impossible layouts; give the campaign fixtures real coordinates A Layout now refuses any turbine with a position off the globe or a rotor diameter that is not positive and finite, and any two turbines closer than the sum of their rotor radii. The check runs in __post_init__, so every way of building one is covered. That rejects placeholder coordinates, which put every turbine at one point and would otherwise pass quietly into pass-2 neighbour ranking, pass-4 wake geometry and campaign design. The benchmarking campaign fixtures used (0, 0) for every turbine when no coordinates were given. They now default to the published Hill of Towie positions: HOT_COORDINATES and hot_coords() hold them without the open-data download, and replace seven copies of a helper that downloaded the metadata to read the same numbers. Test layouts that put turbines closer than a rotor diameter are spread out; the real farms' closest pairs are 271-342 m. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Compare written plot paths independently of the OS separator test_all_three_plots_are_written compared str(relative path) against forward-slash literals, so it failed on Windows only. It now compares as_posix(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Record the converged degradation study in CF21 All three farms, no crashes; the pass-3 floor sweep reproduces CF20 exactly. Most Hill of Towie cases become more robust with a converged pass 2; the 6-turbine subset gets worse and is recorded as not yet diagnosed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Name the northing steps and document the method in one place Rename north_farm's passes 1-4 to reanalysis-anchor, changepoints-v-consensus, changepoints-v-reanalysis and wake-nadir-shift across code, tests and studies (add_wake_nadir -> add_wake_nadir_shift). Describe the method once, in docs/northing.md, and trim the source docstrings and comments to behaviour. Move single-use constants into the function that uses them. List rotor_diameter_m in the campaign turbines file, and drop Layout.from_frame's duplicate rotor-diameter check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Trim remaining justification and single-use constants from the PR Cut the why from the harness, replicates and loader docstrings and the harness comments, and move single-use constants in the northing studies and tests into the function that uses them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Trim the older northing docstrings and inline single-use constants Cut justification from the NorthingSettings, veer, persistence, pruning and required-step docstrings and comments, and move single-use constants in northing.py and test_northing_plots.py into the function that uses them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Show northing's before/after error on Hill of Towie in the northing doc Add a two-panel figure of each turbine's 20-day rolling north error against ERA5, raw and with the golden north table applied, on a shared axis, and the study driver that draws it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Title the northing figure for Hill of Towie and roll it over 30 days Add a suptitle naming the farm, say "circular difference" rather than "vs" in the panel titles, and widen the rolling window from 20 to 30 days. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Break the northing figure's lines at the +/-180 wrap; roll over 14 days A rolling error crossing from -177 to +179 deg was drawn as a full-height vertical line (T01, June 2016). Break each line where consecutive points jump by more than 180 deg, and use a 14-day rolling window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Roll the northing figure with an exact circular median over 14 days rolling_circ_median_approx takes the median of sin and cos separately, which can produce angles in no sample when a turbine steps by about 180 deg (T01, June 2016). Evaluate circ_median over each 14-day window instead, twice a day. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Write before/after wake-nadir pair plots when northing runs Add wake_pair_curves and wake_pairs to expose one pair's binned power and wind-speed ratios, and plot_wake_nadir_pair to draw them before and after the shift. The harness writes one for each of the three turbines shifted most. Call the located wake minimum the nadir throughout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Show each northing step before and after on Hill of Towie in the northing doc Add figures for reanalysis-anchor, changepoints-v-consensus, changepoints-v-reanalysis (T12 and T19 alone) and the wake-nadir shift (farm map and the T21->T19 wake), all drawn by study_northing_doc_plot. Clarify when the neighbour consensus applies and reflow the text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Update northing.md * Use every colour solid before any dash in series_style; clarify consensus sizes in northing.md Regenerates the northing doc figures with the new styles and trims finding references from the northing tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fix mypy errors under Python 3.10 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…146) * Re-north consensus devices one at a time so shared steps cannot flip-flop changepoints-v-consensus re-northed every unsettled device at once, each against a consensus built from the previous round's tables. Neighbours that share a step could then claim it together, drop it together (neither's consensus shows it any more) and claim it again, cycling with period two until the round cap; which tables were kept depended on the cap's parity. Each round now re-norths the unsettled devices one at a time, in name order, against a consensus rebuilt from the latest tables. The non-convergence warning now names the devices still moving and says whether their changepoints changed, instead of reporting "inf deg". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Move the one-at-a-time rationale from the docstring to the northing docs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Re-record the 365-day degradation case and log the wake-nadir fragility it exposed Re-northing one device at a time leaves T11 0.2 deg from before, but one of its wake pairs is marginal and drops out, swinging its wake-nadir correction by 3.6 deg. CF22 records the fix, the evidence and the wake-nadir finding. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Log CF22: the one-at-a-time pass-2 fix and the wake-nadir fragility it exposed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…election, caller-stated toggle cycle (#145) * Add a pairing filter and selection accounting to ToggleSpecialistMethod - pairing_max_gap: keep a used row only if the other segment has a used row within the gap, inclusive. Must be a positive whole multiple of the timebase, so a sub-timebase value can no longer silently drop every row (v0's floored-seconds semantics). Applied after exclude_row, so the uplift and the bootstrap see the same selection. Parity-tested against v0's any_within_timedelta. - MethodOutput.selection_accounting: rows kept per segment after each stage (segment, filters, exclude_row, pairing), also written to a selection CSV; a warning when a stage keeps the two segments' rows unevenly (segment_imbalance_warning). - Pairing-gap diagnostic plot (2_filter): each used row's gap to the nearest used row of the other segment, before and after pairing, with hours kept. Drawn without a pairing filter too, to judge whether one is needed. - Path-separator-agnostic assertion in test_uplift_plots (Windows fix, as on northing-improvement). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Read pairing gaps at nanosecond resolution whatever the index unit index.asi8 is in the index's own unit; real SCADA often arrives as datetime64[us], so a 10-minute gap read as 0.6 s and the pairing filter kept every row. Convert to ns first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add block reference modes and symmetric row exclusion to ToggleSpecialistMethod reference_mode selects what the test power is compared against: "sum" (the summed references, as before), "block_mean" (the mean test power of the row's own fixed wall-clock block, so no references are needed at all) or "block_scaled" (the summed references rescaled per block so each block's reference total equals its test total). Every used row of a block gets the same reference factor, so the on/off ratio cancels the block's wind level exactly; a block whose used rows lack either state is dropped and reported as a new "block" selection stage. The block length defaults to pairing_max_gap. The exclude_row column is now honoured on every turbine's rows, so a flagged reference row removes the timestamp the way a reference outage does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Add a combined reference mode that blends the sum and block_mean estimates reference_mode="combined" runs the sum and block_mean estimates, each on its own row selection, and blends them by inverse variance, headline and per bin. The bootstrap now exposes each cell's resampled uplifts; two bootstraps with the same campaign span, timebase, block length, resample count and seed draw the same blocks, so combine_estimates can take the components' correlation from their draw-paired resamples and report an honest sigma for the blend. A window with no references at all falls back to block_mean with a warning. The uncertainty diagnostics gain the reported sigma and a component column; the selection CSV lists every component's selection. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Report NaN, not float residue, for a bootstrap cell too sparse to fall back on A cell with at most three records a side blends to the fallback alone; when the per-record scatter cannot be measured either, the result is NaN rather than the ~1e-17 std of resamples that all see the same handful of records. That residue read as near-certainty, and in the combined reference mode it took the whole weight. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Refuse to blend components whose bootstrap resamples cannot be paired draw by draw Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Blend correlated components with minimum-variance weights so the blend is never worse than its better leg The weights were plain inverse variance, with the resample correlation entering only the reported sigma; a noisier leg correlated with the better one could then widen the blend beyond the better leg alone. The weights are now wa ∝ max(sb² - r·sa·sb, 0) and wb ∝ max(sa² - r·sa·sb, 0), which drop a strongly correlated noisy leg to zero. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Run the same normal-operation filter on every turbine the toggle specialist uses The test turbine went through NormalOperationFilter while the references got a separate complete-case plus availability check. With the stuck filter off both tests are the same, so nothing changes today; one code path means any future filter change reaches the references. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Add an exclude_block column role: a flagged row removes its whole reference block The pairing filter removes stretches where one toggle state is absent, never the counterpart of a single dropped row, so a caller-side selection that thins one state's rows in particular conditions (a controlled-data residual test, say) leaves the two states with different wind mixes. A row flagged in the exclude_block column drops every row of its one-cycle block, for every turbine and in every reference mode, so both states lose the same conditions. Block ids are now computed whenever a block length is available, sum mode included. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Add reference_choice='min_sigma': the sum estimate takes the reference subset with the smallest sigma Every non-empty subset of the available references is estimated in full (own row selection, bootstrap); the subset with the smallest finite headline sigma becomes the sum estimate, alone or as the blend's reference leg, ties to the larger subset. A reference that tracks badly or is screened out for long stretches loses to the subsets without it. Every subset's result is written to a reference_subsets CSV; the results CSV records the choice and the references used. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Add decision_basis='permuted': judge the subset choice and blend weights on label-permuted bootstraps Ranking reference subsets by the realised uplift's bootstrap sigma, and weighting the combined blend by the legs' realised sigmas, both favour the alternative whose realised uplift happened to come out low, because the on/off contrast noise is skewed: a winner's curse that biases the estimate down and under-reports sigma, and does not shrink with more data. With decision_basis="permuted" each component also bootstraps the same rows with the on/off labels shuffled within each reference block (block on/off counts kept), and the min_sigma choice and the blend weights read that sigma instead. The reported uplift and sigma are always the actual ones. Subsets being ranked skip their actual bootstrap and the winner is re-run, so the cost is unchanged. combine_estimates gains a `weighting` argument (another bootstrap's sigmas and resamples to take the weights from); the blend's variance still uses the actual ones. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ToggleSpecialist is one method: reference subset + block leg, blended, decided on permuted labels Remove the reference_mode, reference_choice and decision_basis options (and the block_scaled mode). The method is always the minimum-variance blend of the reference leg (the summed power of the reference subset with the smallest label-permuted sigma) and the block leg (the test turbine against its own one-cycle block mean), with the subset choice and the blend weights judged on label-permuted bootstraps and the reported uplift and sigma always the actual ones. With no reference turbine the estimate is the block leg alone. reference_block is now a required constructor argument: one toggle cycle, stated by the caller, independent of pairing_max_gap and never inferred. The blend's per-bin n_records counts the used upgraded rows by the merged bin label, so labeled_rows reproduces it; the data-stats CSV counts rows of the turbines the estimate used only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Fix the toggle placebo end-to-end tests - The placebo fixture is 10-minute SCADA: its 100-minute toggle cycle cannot tile an hourly grid, which the required reference_block now refuses. - A component with no resample spread takes the default correlation rather than tripping np.corrcoef's divide warning (identical-turbine placebo). - ToggleSpecialist hands the harness one uncertainty_diagnostics row per (condition, condition_bin), each leg's columns suffixed _<component>; the per-leg rows clashed with the harness's sigma column and its one-row-per-key rule. The uncertainty CSV keeps the per-leg rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ToggleSpecialistMethod power_band: keep only rows whose reference-derived power lies in a band power_band=(lo, hi] restricts each leg's used rows to those whose bin power (rho_label * ref_total, the state-neutral baseline power the bins use) lies in the band, and estimates the uplift over them. The cut is a "power_band" stage in the selection accounting and is written to the run config. A caller measuring the uplift only where a control table is followed uses it instead of cutting on the test turbine's own power, which the state moves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ToggleSpecialistMethod: rename reference_block to toggle_period, drop segment_imbalance_warning toggle_period says what the caller states (one toggle cycle) and no longer reads like the bootstrap block or the block leg. The imbalance warning was hard to interpret; the same per-stage kept fractions stay in selection_accounting and the selection CSV. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ToggleSpecialistMethod: explicit bin power and toggle datum, per-leg diagnostics - bin_power: "baseline" (default) bins and power_band read power no upgraded row enters (rho_base * ref_total on the reference leg, the cycle's baseline-row mean on the block leg); "both_states" keeps the two-state mean, for campaigns with no true baseline state. - toggle_datum (required): the toggle-cycle grid is tiled from it, never from the first record, so a missing record cannot shift cycle boundaries. Toggle studies state one and draw treatment starts only on cycle boundaries. - Data-stats rows and results-CSV ratios per leg, plus the blend weight, so each leg and the headline are re-derivable; plots show one leg's own rows. - Refuse more than 8 references for the subset choice; validate power_band. - ColumnSchema.exclude_row: which turbines' flags count is the method's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ToggleSpecialistMethod: per-bin energy sums per leg, with each bin's blend weight The blended per-bin frame took sum_actual / sum_counterfactual from one leg while p50_uplift is the blend. Each leg's frame now carries sum_actual_<leg> / sum_counterfactual_<leg>, and the blended frame carries both legs' and the bin's weight_sum, so every bin's blend is re-derivable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ToggleSpecialistMethod: drop degenerate bootstrap resamples, report each leg's uplift A block-bootstrap resample whose on-side or off-side reference energy is below a tenth of the whole campaign's (a calm spell or outage drawn over and over) now counts as non-finite instead of feeding the spread: on a near-calm fortnight the reported sigma reached 10^5 %. Every row of uncertainty_diagnostics carries uplift, per leg and for the blend, overall and per bin, so a consumer can see which leg an error came from without re-running. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…147) The per-run folder already carries the run name, so repeating it in every file name and adding microseconds only lengthens paths: a reference-subsets CSV ran to 88 characters on its own and tripped tools with a path limit. Files are now <kind>_<YYYYmmdd_HHMMSS>.csv. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
v0 is a single-method tool: it measures turbine-upgrade uplift with a binned power-curve, test-vs-reference method. v1 turns wind-up into a platform in which alternative uplift methods are pluggable and objectively benchmarked on synthetic datasets with known ground truth. The driving goals are accurate and precise results from short campaigns and richer conditional information about how an upgrade performs (uplift by wind speed, uplift in wakes vs free-stream, day vs night, by direction/stability, etc.).
Metrics:
Design considerations: