Conversation
…ture
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
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
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
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
…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
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
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
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
…fault
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
aclerc
left a comment
There was a problem hiding this comment.
some comments from a few days ago; these probably still stand but I will start a new review now
There was a problem hiding this comment.
🟡 Changes recommended
Offset handling, fixture execution, Greenbyte loading, and benchmark acceptance artifacts have unresolved correctness gaps.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Introduces shared north-calibration across campaign and study paths, including diagnostics, synthetic faults, and power-model direction features.
Changes:
- Adds two-pass northing integration and extensive regression coverage.
- Adds northing fault campaigns and reference-direction model features.
- Removes
rupturesand adds Greenbyte dataset support.
File summaries
| File | Description |
|---|---|
uv.lock |
Updates locked dependencies. |
tests/wind_up/test_northing.py |
Tests northing behavior extensively. |
tests/wind_up/test_northing_real_data.py |
Adds real-data regressions. |
tests/test_optimize_northing.py |
Updates legacy integration tests. |
tests/test_data/hot/northing/northing_inputs.parquet |
Adds LFS regression fixture. |
tests/benchmarking/synthetic/test_faults.py |
Tests fault injection. |
tests/benchmarking/synthetic/sources/test_greenbyte.py |
Tests Greenbyte loading. |
tests/benchmarking/harness/test_northing.py |
Tests shared preprocessing. |
tests/benchmarking/campaigns/test_northing_fixture.py |
Tests R1 fixture declarations. |
tests/benchmarking/baselines/test_power_model_method.py |
Adds northed yaw fixtures. |
tests/benchmarking/baselines/test_power_model_features.py |
Tests direction features. |
src/wind_up/northing_plots.py |
Adds northing diagnostics. |
src/wind_up/circular_math.py |
Hosts shared circular math. |
src/wind_up_v0/circular_math.py |
Re-exports shared implementation. |
pyproject.toml |
Removes ruptures. |
docs/v1/issues_campaigns.md |
Expands R1 requirements. |
docs/v1/findings_campaigns.md |
Records northing findings. |
docs/superpowers/specs/2026-09-01-c2-campaign-context-seam-design.md |
Removes development design. |
docs/superpowers/specs/2026-08-28-w0-src-layout-rename-design.md |
Removes development design. |
docs/superpowers/specs/2026-08-28-robustness-failure-modes-design.md |
Removes development design. |
docs/superpowers/specs/2026-08-28-c1-campaign-runner-placebo-design.md |
Removes development design. |
docs/superpowers/specs/2026-08-27-realistic-campaigns-design.md |
Removes development design. |
benchmarking/synthetic/sources/greenbyte.py |
Adds Greenbyte adapter. |
benchmarking/synthetic/generator.py |
Injects synthetic faults. |
benchmarking/synthetic/faults.py |
Defines northing faults. |
benchmarking/synthetic/__init__.py |
Exports fault APIs. |
benchmarking/harness/scoring.py |
Accepts ERA5 direction. |
benchmarking/harness/replicates.py |
Norths generated replicates. |
benchmarking/harness/northing.py |
Implements shared preprocessing. |
benchmarking/campaigns/runner.py |
Integrates campaign northing. |
benchmarking/campaigns/northing_fixture.py |
Adds R1 campaign driver. |
benchmarking/campaigns/declaration.py |
Adds fault/discovery declarations. |
benchmarking/baselines/study_toggle_methods_compare.py |
Enables study northing. |
benchmarking/baselines/study_power_model_compare.py |
Enables study northing. |
benchmarking/baselines/study_northing_subsets.py |
Adds consistency study. |
benchmarking/baselines/power_model/method.py |
Enables direction features. |
benchmarking/baselines/power_model/features.py |
Builds circular direction features. |
benchmarking/baselines/inspect_wake_steering_case.py |
Adds shared northing. |
benchmarking/baselines/inspect_prepost_hard_case.py |
Adds replicate northing. |
benchmarking/baselines/example_toggle_study.py |
Supplies ERA5 direction. |
benchmarking/baselines/example_prepost_study.py |
Supplies ERA5 direction. |
.gitignore |
Ignores local design documents. |
.gitattributes |
Configures northing fixture for LFS. |
Review details
- Files reviewed: 44/46 changed files
- Comments generated: 9
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ent 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
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
`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
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
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
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
…enchmarks 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
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
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
There was a problem hiding this comment.
🟡 Changes recommended
Northing can proceed without an absolute anchor, absorb wake-steering treatment signals, lacks required plot integration, and retains a stale Windows benchmark.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
benchmarking/baselines/study_toggle_methods_compare.py:250
- The Windows platform baseline is still stamped
2026-07-15/de85f84, before this call began northing replicates and before the default direction feature was enabled. Windows runs will therefore compare the new power model against old-feature results and report misleading regressions. Regeneratestudy_toggle_methods_compare_baseline_win32.jsonon the Windows benchmark machine.
methods=methods,
study=study,
profile_name=profile_name,
era5_wd=era5_wd,
- Files reviewed: 56/59 changed files
- Comments generated: 3
- Review effort level: Balanced
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
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
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
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
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
_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
…e 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
See issue R1 in
issues_campaigns.md