Skip to content

New north correction method - #138

Merged
aclerc merged 26 commits into
v1from
v1-R1
Sep 4, 2026
Merged

New north correction method#138
aclerc merged 26 commits into
v1from
v1-R1

Conversation

@aclerc

@aclerc aclerc commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

See issue R1 in issues_campaigns.md

aclerc and others added 2 commits September 2, 2026 15:08
…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
@aclerc
aclerc changed the base branch from main to v1 September 2, 2026 15:51
aclerc and others added 7 commits September 2, 2026 18:41
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 aclerc left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some comments from a few days ago; these probably still stand but I will start a new review now

Comment thread src/wind_up/circular_math.py
Comment thread src/wind_up/circular_math.py Outdated
Comment thread src/wind_up/northing.py Outdated
Comment thread src/wind_up/northing.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 ruptures and 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.

Comment thread benchmarking/harness/northing.py
Comment thread benchmarking/synthetic/sources/greenbyte.py Outdated
Comment thread benchmarking/baselines/power_model/method.py
Comment thread benchmarking/baselines/study_power_model_compare.py
Comment thread benchmarking/baselines/study_toggle_methods_compare.py
Comment thread benchmarking/campaigns/northing_fixture.py
Comment thread benchmarking/campaigns/northing_fixture.py
Comment thread benchmarking/campaigns/runner.py Outdated
Comment thread tests/wind_up/test_northing_real_data.py
aclerc and others added 9 commits September 3, 2026 17:02
…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
@aclerc
aclerc marked this pull request as ready for review September 4, 2026 06:08
@aclerc
aclerc requested a balanced review from Copilot September 4, 2026 06:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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. Regenerate study_toggle_methods_compare_baseline_win32.json on 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

Comment thread src/wind_up/northing.py
Comment thread benchmarking/baselines/inspect_wake_steering_case.py
Comment thread benchmarking/harness/northing.py
aclerc and others added 3 commits September 4, 2026 08:54
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
aclerc and others added 5 commits September 4, 2026 09:54
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
@aclerc
aclerc merged commit f950ee0 into v1 Sep 4, 2026
@aclerc
aclerc deleted the v1-R1 branch September 4, 2026 10:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants