From 9d8737bc067b436e5b5af948204cde7c9945004d Mon Sep 17 00:00:00 2001 From: aclerc Date: Wed, 2 Sep 2026 15:08:34 +0100 Subject: [PATCH 01/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- .../baselines/power_model/features.py | 61 +- benchmarking/baselines/power_model/method.py | 10 + benchmarking/campaigns/declaration.py | 19 +- benchmarking/campaigns/methods.py | 5 + benchmarking/campaigns/northing.py | 187 +++++ benchmarking/campaigns/northing_fixture.py | 276 +++++++ benchmarking/campaigns/runner.py | 39 +- benchmarking/synthetic/__init__.py | 3 + benchmarking/synthetic/faults.py | 95 +++ benchmarking/synthetic/generator.py | 10 + .../specs/2026-09-02-r1-northing-design.md | 715 +++++++++++++++++ docs/v1/issues_campaigns.md | 19 +- pyproject.toml | 2 - src/wind_up/circular_math.py | 130 +++ src/wind_up/northing.py | 706 +++++++++++++++++ src/wind_up_v0/circular_math.py | 138 +--- src/wind_up_v0/optimize_northing.py | 747 ++++-------------- .../baselines/test_power_model_features.py | 96 +++ tests/benchmarking/campaigns/test_northing.py | 151 ++++ .../campaigns/test_northing_fixture.py | 133 ++++ tests/benchmarking/synthetic/test_faults.py | 144 ++++ tests/test_optimize_northing.py | 151 +--- tests/wind_up/test_northing.py | 504 ++++++++++++ uv.lock | 112 +-- 24 files changed, 3514 insertions(+), 939 deletions(-) create mode 100644 benchmarking/campaigns/northing.py create mode 100644 benchmarking/campaigns/northing_fixture.py create mode 100644 benchmarking/synthetic/faults.py create mode 100644 docs/superpowers/specs/2026-09-02-r1-northing-design.md create mode 100644 src/wind_up/circular_math.py create mode 100644 src/wind_up/northing.py create mode 100644 tests/benchmarking/campaigns/test_northing.py create mode 100644 tests/benchmarking/campaigns/test_northing_fixture.py create mode 100644 tests/benchmarking/synthetic/test_faults.py create mode 100644 tests/wind_up/test_northing.py diff --git a/benchmarking/baselines/power_model/features.py b/benchmarking/baselines/power_model/features.py index 7579d840..94185cb3 100644 --- a/benchmarking/baselines/power_model/features.py +++ b/benchmarking/baselines/power_model/features.py @@ -5,8 +5,10 @@ This matrix is deliberately **curated** to features known to relate to the *cause* of the test turbine's power — weather and wakes: -* per **reference turbine**: active power (the primary stable weather-driven measurement) and the - availability counter (whether the reference is operating, hence whether it is making a wake); +* per **reference turbine**: active power (the primary stable weather-driven measurement), the + availability counter (whether the reference is operating, hence whether it is making a wake), + and optionally the **north-calibrated** direction as ``sin``/``cos`` (where each reference is + pointing is much of what resolves who is waking whom); * all raw **ERA5** columns, passed through under their original Open-Meteo names (no renaming), with derived ``sin``/``cos`` companions for the circular wind-direction fields. @@ -20,6 +22,7 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING import numpy as np @@ -32,6 +35,10 @@ # Separator between a source-native tag and the turbine it came from in a feature name. QUALIFIER = " @ " +# The prefix the shared northing step puts on a north-calibrated column. +_NORTHED_PREFIX = "northed_" + +logger = logging.getLogger(__name__) def _references(scada_df: pd.DataFrame, *, test_wtg: str, turbine_col: str) -> list[str]: @@ -55,6 +62,7 @@ def build_reference_features( availability_col: str, extra_cols: Sequence[str] = (), include_availability: bool = True, + direction_col: str | None = None, ) -> pd.DataFrame: """Wide, curated reference features: each reference turbine's active power (+ optional extras). @@ -70,8 +78,16 @@ def build_reference_features( :param include_availability: when ``False``, drop the per-reference availability *feature* (removal-ablation knob); ``availability_col`` must still exist in ``scada_df`` (the downtime filter needs it), so its presence is validated either way + :param direction_col: the **north-calibrated** direction column each reference contributes as + ``sin``/``cos`` companions (the raw degrees are never a feature: a tree cannot see that + 359 degrees is next to 1). Must be the column the shared northing step writes; raises + naming it when absent. A raw direction listed in ``extra_cols`` is dropped in favour of + it, so a reference never contributes both. """ refs = _references(scada_df, test_wtg=test_wtg, turbine_col=turbine_col) + extra_cols, direction_frame = _direction_features( + scada_df, refs=refs, turbine_col=turbine_col, direction_col=direction_col, extra_cols=extra_cols + ) value_cols = [active_power_col, *([availability_col] if include_availability else []), *extra_cols] # availability_col stays validated even when not featured: it is a required input and the # docstring contract is that it exists for the downstream downtime filter. @@ -89,10 +105,51 @@ def build_reference_features( features.columns = [f"{col}{QUALIFIER}{r}" for col, r in keep] features = features.reindex(index) features.index.name = index.name + if direction_frame is not None: + features = features.join(direction_frame.reindex(index), how="left") check_reference_only(features.columns.tolist(), test_wtg=test_wtg) return features +def _direction_features( + scada_df: pd.DataFrame, + *, + refs: list[str], + turbine_col: str, + direction_col: str | None, + extra_cols: Sequence[str], +) -> tuple[tuple[str, ...], pd.DataFrame | None]: + """Return ``extra_cols`` with raw directions removed, and the per-reference sin/cos frame. + + ``(None, ...)`` in, ``(extra_cols unchanged, None)`` out: a caller that asks for no direction + keeps exactly the feature set it had before. + """ + if direction_col is None: + return tuple(extra_cols), None + if direction_col not in scada_df.columns: + msg = ( + f"the north-calibrated direction column {direction_col!r} is not in scada_df; the shared " + f"northing step must run before the power model, which reads the northed direction rather " + f"than the raw one. Columns present: {sorted(scada_df.columns)}" + ) + raise ValueError(msg) + raw = direction_col.removeprefix(_NORTHED_PREFIX) + kept = tuple(c for c in extra_cols if c not in {raw, direction_col}) + if len(kept) != len(extra_cols): + logger.info("dropping raw direction %r from features in favour of %r", raw, direction_col) + + index = pd.DatetimeIndex(pd.unique(scada_df.index)).sort_values() + columns = {} + for ref in refs: + rows = scada_df[scada_df[turbine_col] == ref] + series = pd.Series(rows[direction_col].to_numpy(dtype=float), index=pd.DatetimeIndex(rows.index)) + series = series[~series.index.duplicated()].reindex(index) + rad = np.deg2rad(series.to_numpy(dtype=float)) + columns[f"{direction_col}_sin{QUALIFIER}{ref}"] = np.sin(rad) + columns[f"{direction_col}_cos{QUALIFIER}{ref}"] = np.cos(rad) + return kept, pd.DataFrame(columns, index=index) + + def era5_feature_frame(aligned_era5: pd.DataFrame) -> pd.DataFrame: """Turn aligned ERA5 into model features: all raw columns passed through + dir sin/cos companions. diff --git a/benchmarking/baselines/power_model/method.py b/benchmarking/baselines/power_model/method.py index 3cad6388..b0846d3a 100644 --- a/benchmarking/baselines/power_model/method.py +++ b/benchmarking/baselines/power_model/method.py @@ -279,6 +279,11 @@ class PowerModelMethod: value keeps the strict raise-on-unknown-column typo guard. :param availability_feature: when ``False`` (the accepted default), drop the per-reference availability *feature*; the ``availability`` role itself stays required for the downtime filter + :param direction_feature: when ``True``, each reference contributes its **north-calibrated** + direction as ``sin``/``cos``. Requires the shared northing step to have written + ``columns.northed("nacelle_position")`` into the frame, and raises naming that column when + it has not; the raw nacelle position is never used, northed or not. The test turbine's own + direction stays barred (design-note §3): northing does not make a post-treatment signal safe. :param adaptive_time_decay: when ``True`` (**default**, the Issue 15 self-configuring behaviour) the headline fit's time-decay half-life is set automatically to ``_TIME_DECAY_CAMPAIGN_MULTIPLE * campaign_duration_days`` — a short half-life for a short @@ -313,12 +318,15 @@ class PowerModelMethod: reference_stat_cols: tuple[str, ...] = () era5_exclude: tuple[str, ...] = CURATED_ERA5_EXCLUDE availability_feature: bool = False + direction_feature: bool = False adaptive_time_decay: bool = True time_decay_half_life_days: float | None = None def __post_init__(self) -> None: """Validate ``columns`` names every role this method reads, and the requested ``conditions``.""" self.columns.require_roles(("active_power", "active_power_min", "availability", "wind_speed", "wind_speed_sd")) + if self.direction_feature: + self.columns.require_roles(("nacelle_position",)) validate_conditions(self.conditions, supported=_SUPPORTED_CONDITIONS, method_name=self.name) def estimate(self, mi: MethodInput) -> MethodOutput: @@ -352,6 +360,7 @@ def estimate(self, mi: MethodInput) -> MethodOutput: availability_col=self.columns.availability, extra_cols=extra_cols, include_availability=self.availability_feature, + direction_col=self.columns.northed("nacelle_position") if self.direction_feature else None, ) features, era5 = self._add_era5(scada, features, mi=mi, index=index, timebase=timebase) check_reference_only(features.columns.tolist(), test_wtg=mi.test_wtg) @@ -1000,6 +1009,7 @@ def _config_params(self) -> dict[str, Any]: "reference_stat_cols": list(self.reference_stat_cols), "era5_exclude": list(self.era5_exclude), "availability_feature": self.availability_feature, + "direction_feature": self.direction_feature, "model_params": {**TUNED_MODEL_PARAMS, **self.model_params}, "adaptive_time_decay": self.adaptive_time_decay, "time_decay_half_life_days": self.time_decay_half_life_days, diff --git a/benchmarking/campaigns/declaration.py b/benchmarking/campaigns/declaration.py index 3eb2c00b..7e3f0cf4 100644 --- a/benchmarking/campaigns/declaration.py +++ b/benchmarking/campaigns/declaration.py @@ -32,7 +32,10 @@ class CampaignSpec: :param candidate_references: turbines a method may use as references :param excluded_turbines: turbines whose data must not be used at all :param coords: turbine name to ``(latitude, longitude)`` in degrees - :param north_offsets: step-applied northing corrections, ``(turbine, from, offset_deg)`` + :param north_offsets: step-applied northing corrections, ``(turbine, from, offset_deg)``. + ``None`` (the default) means the analyst supplied none and the shared northing step + discovers them from the data -- the usual case. A list, **including an empty one**, + is applied exactly as given and nothing is discovered. :param rated_power_kw: the turbines' rated power :param analysis_period: ``(start, end)`` of the whole record, end exclusive :param turbine_col: the turbine-identifier column of the SCADA frame @@ -43,7 +46,7 @@ class CampaignSpec: candidate_references: list[str] excluded_turbines: list[str] coords: dict[str, tuple[float, float]] - north_offsets: list[tuple[str, pd.Timestamp, float]] + north_offsets: list[tuple[str, pd.Timestamp, float]] | None rated_power_kw: float analysis_period: tuple[pd.Timestamp, pd.Timestamp] turbine_col: str = HOT_COLUMNS.turbine @@ -86,8 +89,12 @@ class SyntheticCampaign: :param upgrade_timing: changeover timestamp (prepost) or ``ToggleSchedule`` (toggle) :param candidate_references: turbines offered to methods as references :param upgrades: the upgrade callables to inject; empty for a placebo + :param faults: measurement corruptions to inject after the upgrades (an R-series fault such + as :class:`~benchmarking.synthetic.faults.NorthingStep`). Private ground truth like + ``upgrades``: ``CampaignSpec`` never carries them, so a method must cope undeclared. :param coords: turbine name to ``(latitude, longitude)`` in degrees - :param north_offsets: step-applied northing corrections, ``(turbine, from, offset_deg)`` + :param north_offsets: step-applied northing corrections, ``(turbine, from, offset_deg)``; + ``None`` leaves them to be discovered (see :class:`CampaignSpec`) :param rated_power_kw: the turbines' rated power :param analysis_period: ``(start, end)`` of the whole record, end exclusive :param excluded_turbines: turbines whose data must not be used @@ -100,9 +107,10 @@ class SyntheticCampaign: candidate_references: list[str] upgrades: list coords: dict[str, tuple[float, float]] - north_offsets: list[tuple[str, pd.Timestamp, float]] + north_offsets: list[tuple[str, pd.Timestamp, float]] | None rated_power_kw: float analysis_period: tuple[pd.Timestamp, pd.Timestamp] + faults: list = field(default_factory=list) excluded_turbines: list[str] = field(default_factory=list) columns: ColumnSchema = HOT_COLUMNS seed: int = 0 @@ -123,7 +131,7 @@ def spec(self) -> CampaignSpec: candidate_references=list(self.candidate_references), excluded_turbines=list(self.excluded_turbines), coords=dict(self.coords), - north_offsets=list(self.north_offsets), + north_offsets=None if self.north_offsets is None else list(self.north_offsets), rated_power_kw=self.rated_power_kw, analysis_period=self.analysis_period, turbine_col=self.columns.turbine, @@ -140,6 +148,7 @@ def generate(self, scada_df: pd.DataFrame) -> SyntheticDataset: upgrades=list(self.upgrades), mode="toggle" if isinstance(self.upgrade_timing, ToggleSchedule) else "prepost", upgrade_timing=self.upgrade_timing, + faults=list(self.faults), rated_power_kw=self.rated_power_kw, columns=self.columns, seed=self.seed, diff --git a/benchmarking/campaigns/methods.py b/benchmarking/campaigns/methods.py index 6f36679a..8c332595 100644 --- a/benchmarking/campaigns/methods.py +++ b/benchmarking/campaigns/methods.py @@ -24,6 +24,7 @@ def carried_forward_methods( out_dir: Path, era5_hourly_df: pd.DataFrame | None = None, include_power_model: bool = True, + direction_feature: bool = False, ) -> list[Method]: """Build the methods applicable to ``spec``, each writing into its own subfolder of ``out_dir``. @@ -35,6 +36,9 @@ def carried_forward_methods( :param out_dir: the turbine's output folder; each method gets a subfolder named after it :param era5_hourly_df: reanalysis for the power model; omit to run it without ERA5 features :param include_power_model: build the power model (needs the ``ml`` dependency group) + :param direction_feature: give the power model each reference's north-calibrated direction. + Requires the shared northing step to have run over the frame, so it is off unless the + caller knows the runner northed. """ methods: list[Method] = [NaiveRatioMethod(columns=HOT_COLUMNS, out_dir=out_dir / "naive_ratio", save_plots=True)] if spec.mode == "toggle": @@ -55,6 +59,7 @@ def carried_forward_methods( era5_hourly_df=era5_hourly_df, conditions=PowerModelMethod.conditions if era5_hourly_df is not None else (), availability_feature=False, + direction_feature=direction_feature, era5_exclude=CURATED_ERA5_EXCLUDE, model_params=dict(TUNED_MODEL_PARAMS), out_dir=out_dir / "power_model", diff --git a/benchmarking/campaigns/northing.py b/benchmarking/campaigns/northing.py new file mode 100644 index 00000000..8a002190 --- /dev/null +++ b/benchmarking/campaigns/northing.py @@ -0,0 +1,187 @@ +"""The shared northing step: north-calibrate every turbine's direction, upstream of every method. + +Runs in the campaign runner, which holds the :class:`~benchmarking.campaigns.declaration.CampaignSpec`, +so every method inherits the correction rather than each hand-rolling one. The step writes +``columns.northed(role)`` alongside the untouched original, so plots and diagnostics of the raw +signal keep meaning what they say; whether it has run is written in the frame as the presence of +that column, with no separate flag to disagree with it. + +``spec.north_offsets`` decides which of two things happens: + +* ``None`` (the default) -- discover the corrections from the data; +* a list (possibly empty) -- apply exactly those, discovering nothing. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import numpy as np +import pandas as pd + +from wind_up.northing import DEFAULT_NORTHING, NorthingSettings, apply_north_table, north_farm, yaw_usable + +if TYPE_CHECKING: + from collections.abc import Sequence + + from benchmarking.campaigns.declaration import CampaignSpec + from benchmarking.synthetic import ColumnSchema + +logger = logging.getLogger(__name__) + +# The direction roles corrected by default. One table per turbine is derived from its nacelle +# position and may be applied to further direction channels of the same turbine. +DEFAULT_NORTHING_ROLES: tuple[str, ...] = ("nacelle_position",) + + +def _north_table_from_offsets( + offsets: Sequence[tuple[str, pd.Timestamp, float]], *, turbine: str, start: pd.Timestamp +) -> pd.DataFrame: + """Return one turbine's declared north table, or a zero-offset table when none is declared.""" + rows = sorted(((ts, off) for (t, ts, off) in offsets if t == turbine), key=lambda e: e[0]) + if not rows: + return pd.DataFrame({"timestamp": pd.DatetimeIndex([start]), "north_offset": [0.0]}) + return pd.DataFrame( + {"timestamp": pd.DatetimeIndex([ts for ts, _ in rows]), "north_offset": [off for _, off in rows]} + ) + + +def _usable_masks( + scada_df: pd.DataFrame, + *, + columns: ColumnSchema, + turbines: Sequence[str], + index: pd.DatetimeIndex, + reference_deg: np.ndarray, + rated_power_kw: float, + timebase_s: float, +) -> dict[str, np.ndarray]: + """Per-turbine rows usable for northing, positional on the shared ``index``.""" + masks = {} + for turbine in turbines: + rows = scada_df[scada_df[columns.turbine] == turbine] + frame = rows[~rows.index.duplicated()].reindex(index) + power = frame[columns.active_power].to_numpy(dtype=float) + # the schema's availability is a "ready to operate" counter, so downtime is what is left + available = frame[columns.availability].to_numpy(dtype=float) + masks[turbine] = yaw_usable( + power=power, + downtime_s=timebase_s - np.nan_to_num(available, nan=0.0), + reference_deg=reference_deg, + rated_power=rated_power_kw, + timebase_s=timebase_s, + ) + return masks + + +def _directions( + scada_df: pd.DataFrame, *, columns: ColumnSchema, turbines: Sequence[str], index: pd.DatetimeIndex, col: str +) -> dict[str, np.ndarray]: + """Per-turbine direction signal, positional on the shared ``index``.""" + out = {} + for turbine in turbines: + rows = scada_df[scada_df[columns.turbine] == turbine] + frame = rows[~rows.index.duplicated()].reindex(index) + out[turbine] = frame[col].to_numpy(dtype=float) + return out + + +def north_campaign_scada( + scada_df: pd.DataFrame, + *, + spec: CampaignSpec, + columns: ColumnSchema, + era5_wd: pd.Series | None = None, + roles: Sequence[str] = DEFAULT_NORTHING_ROLES, + settings: NorthingSettings = DEFAULT_NORTHING, +) -> pd.DataFrame: + """Return ``scada_df`` with a north-calibrated companion column for each direction role. + + One north table per turbine is derived from its ``nacelle_position`` and applied to every + requested role, so a turbine's channels stay mutually consistent. The originals are untouched. + + :param scada_df: long-format SCADA, timestamps indexed, turbines in ``columns.turbine`` + :param spec: the campaign, read for ``north_offsets``, ``rated_power_kw`` and the turbine column + :param columns: the source-native schema naming the direction role(s) + :param era5_wd: reanalysis wind direction (deg) covering the frame, the absolute anchor for + discovery. Required when ``spec.north_offsets`` is ``None``. + :param roles: the direction roles to write a ``northed_`` companion for + :param settings: how the changepoint search is bounded, when discovering + :return: a copy of ``scada_df`` with ``columns.northed(role)`` added for each role + """ + columns.require_roles(roles) + scada_df = scada_df.copy() + index = pd.DatetimeIndex(scada_df.index.unique()).sort_values() + turbines = sorted(str(t) for t in scada_df[columns.turbine].unique()) + # A source that does not ship a direction channel has nothing to north; that is a property of + # the data, not an error. ``require_roles`` has already checked the schema names the roles. + present = [role for role in roles if getattr(columns, role) in scada_df.columns] + skipped = [role for role in roles if role not in present] + if skipped: + logger.info("no northing for role(s) %s: their columns are not in scada_df", skipped) + if not turbines or not present: + return scada_df + roles = present + + if spec.north_offsets is not None: + tables = { + wtg: _north_table_from_offsets(spec.north_offsets, turbine=wtg, start=index.min()) for wtg in turbines + } + logger.info("applying %d declared northing correction(s); discovering none", len(spec.north_offsets)) + else: + if era5_wd is None: + msg = ( + "north_campaign_scada needs era5_wd to discover northing corrections: reanalysis is the " + "absolute anchor, without which a farm that is uniformly wrong looks self-consistent. " + "Supply era5_wd, or declare spec.north_offsets to apply a known table instead." + ) + raise ValueError(msg) + reference = era5_wd.reindex(index).to_numpy(dtype=float) + timebase_s = _timebase_seconds(index) + source = columns.nacelle_position + if source is None or source not in scada_df.columns: + msg = ( + f"northing discovery needs the nacelle_position column {source!r}, which is not in scada_df; " + f"the north table for every role is derived from it. Columns present: {sorted(scada_df.columns)}" + ) + raise ValueError(msg) + tables = north_farm( + index, + direction_deg=_directions(scada_df, columns=columns, turbines=turbines, index=index, col=source), + usable=_usable_masks( + scada_df, + columns=columns, + turbines=turbines, + index=index, + reference_deg=reference, + rated_power_kw=spec.rated_power_kw, + timebase_s=timebase_s, + ), + reanalysis_deg=reference, + settings=settings, + ) + found = sum(len(t) - 1 for t in tables.values()) + logger.info("discovered %d northing changepoint(s) across %d turbines", found, len(turbines)) + + turbine_of = scada_df[columns.turbine].to_numpy() + row_index = pd.DatetimeIndex(scada_df.index) + for role in roles: + source_col = getattr(columns, role) + target = columns.northed(role) + values = scada_df[source_col].to_numpy(dtype=float).copy() + for wtg, table in tables.items(): + rows = turbine_of == wtg + if not rows.any(): + continue + values[rows] = apply_north_table(row_index[rows], values[rows], north_table=table) + scada_df[target] = values + return scada_df + + +def _timebase_seconds(index: pd.DatetimeIndex) -> float: + """Return the frame's record length in seconds, from the most common gap between timestamps.""" + if len(index) < 2: # noqa: PLR2004 - two timestamps are needed for a gap + return 600.0 + gaps = pd.Series(index).diff().dropna() + return float(gaps.mode().iloc[0].total_seconds()) if len(gaps) else 600.0 diff --git a/benchmarking/campaigns/northing_fixture.py b/benchmarking/campaigns/northing_fixture.py new file mode 100644 index 00000000..f1d38786 --- /dev/null +++ b/benchmarking/campaigns/northing_fixture.py @@ -0,0 +1,276 @@ +"""The R1 northing fixture: does a northing step bite, and does the shared step close the gap. + +A small campaign -- T06 plus its three nearest turbines whose northing is stable over the +period -- with a known AeroUp-shaped uplift injected, run four ways per mode: + +| | northing off | northing on | +|----------|-------------------|-------------------| +| clean | the reference error | must be no worse (*no harm*) | +| faulted | must be much worse (*bites*) | must return to ~clean (*fixed*) | + +"Northing off" is a campaign declaring ``north_offsets=[]``: the shared step still writes the +``northed_`` column methods read, but as an uncorrected copy of the raw signal. "Northing on" +declares ``None`` and the step discovers the corrections from the data. + +Run it:: + + uv run python -m benchmarking.campaigns.northing_fixture + +Outputs land under ``WIND_UP_BENCHMARKING_OUTPUT_DIR``/``northing_fixture``/``/``. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import TYPE_CHECKING, Literal + +import matplotlib as mpl + +mpl.use("Agg") # headless: the report writes plots without a display + +import numpy as np +import pandas as pd + +from benchmarking.baselines.hot_context import build_hot_v0_context +from benchmarking.campaigns.declaration import SyntheticCampaign +from benchmarking.campaigns.methods import carried_forward_methods +from benchmarking.campaigns.runner import CampaignRunner +from benchmarking.synthetic import HOT_RATED_POWER_KW, NorthingStep, ToggleSchedule, WindSpeedCpChange +from benchmarking.synthetic.sources.hill_of_towie import load_hot_metadata, load_hot_scada + +if TYPE_CHECKING: + from collections.abc import Sequence + + from benchmarking.campaigns.runner import CampaignResult + +logger = logging.getLogger(__name__) + +# T06 is the measured-best fixture turbine; its three nearest neighbours whose northing is stable +# over 2017-2018 are the references (T05 is nearer but carries real northing steps of its own). +FIXTURE_TEST_WTG = "T06" +FIXTURE_REFERENCES = ("T15", "T10", "T08") +FIXTURE_TURBINES = (FIXTURE_TEST_WTG, *FIXTURE_REFERENCES) + +CAMPAIGN_START = pd.Timestamp("2018-01-01", tz="UTC") +BASELINE_MONTHS = 12 +CAMPAIGN_MONTHS = {"prepost": 12, "toggle": 6} +TOGGLE_PERIOD = pd.Timedelta(minutes=100) + +# The AeroUp shape: +10% Cp held below 5 m/s, fading linearly to zero by 12 m/s. +UPLIFT = (WindSpeedCpChange(ws_points=(5.0, 12.0), deltas=(0.10, 0.0)),) + +# The injected fault. It lands on the nearest reference (the most influential one) at the moment +# the contrast is measured across -- the changeover in prepost, mid-campaign in toggle -- which is +# where a direction corruption does most damage. +FAULT_TURBINE = "T15" +FAULT_OFFSET_DEG = 40.0 + +ERA5_WD_COL = "wind_direction_100m" + + +def analysis_period(mode: Literal["prepost", "toggle"]) -> tuple[pd.Timestamp, pd.Timestamp]: + """Return the whole record the methods see for ``mode``: the baseline plus the campaign.""" + return ( + CAMPAIGN_START - pd.DateOffset(months=BASELINE_MONTHS), + CAMPAIGN_START + pd.DateOffset(months=CAMPAIGN_MONTHS[mode]), + ) + + +def fault_time(mode: Literal["prepost", "toggle"]) -> pd.Timestamp: + """When the injected step lands: at the changeover (prepost) or mid-campaign (toggle).""" + if mode == "prepost": + return CAMPAIGN_START + _, end = analysis_period(mode) + return CAMPAIGN_START + (end - CAMPAIGN_START) / 2 + + +def default_output_root() -> Path: + """Return the directory this driver writes under (``WIND_UP_BENCHMARKING_OUTPUT_DIR`` overrides).""" + root = Path(os.getenv("WIND_UP_BENCHMARKING_OUTPUT_DIR", Path.home() / "temp" / "wind-up-benchmarking")) + return root / "northing_fixture" + + +def _coords(turbines: Sequence[str]) -> dict[str, tuple[float, float]]: + """Hill of Towie coordinates for ``turbines``.""" + metadata = load_hot_metadata() + return { + str(row.Name): (float(row.Latitude), float(row.Longitude)) + for row in metadata.itertuples() + if str(row.Name) in set(turbines) + } + + +def fixture_campaign( + mode: Literal["prepost", "toggle"], + *, + faulted: bool, + northing: bool, + coords: dict[str, tuple[float, float]] | None = None, +) -> SyntheticCampaign: + """Declare one cell of the fixture's 2x2. + + :param mode: ``"prepost"`` or ``"toggle"`` + :param faulted: inject the northing step into :data:`FAULT_TURBINE` + :param northing: ``True`` leaves ``north_offsets`` undeclared so the shared step discovers + them; ``False`` declares an empty list, so the northed column is an uncorrected copy + :param coords: turbine coordinates; a placeholder is used when omitted + """ + if mode == "prepost": + timing: pd.Timestamp | ToggleSchedule = CAMPAIGN_START + elif mode == "toggle": + timing = ToggleSchedule(period=TOGGLE_PERIOD, start=CAMPAIGN_START) + else: + msg = f"unknown mode {mode!r}; expected 'prepost' or 'toggle'" + raise ValueError(msg) + faults = [NorthingStep(turbine=FAULT_TURBINE, at=fault_time(mode), offset_deg=FAULT_OFFSET_DEG)] if faulted else [] + return SyntheticCampaign( + upgraded_turbines=[FIXTURE_TEST_WTG], + upgrade_timing=timing, + candidate_references=list(FIXTURE_REFERENCES), + upgrades=list(UPLIFT), + faults=faults, + coords=coords if coords is not None else dict.fromkeys(FIXTURE_TURBINES, (0.0, 0.0)), + north_offsets=None if northing else [], + rated_power_kw=HOT_RATED_POWER_KW, + analysis_period=analysis_period(mode), + ) + + +def _era5_direction(era5_df: pd.DataFrame, index: pd.DatetimeIndex) -> pd.Series: + """Return the hourly ERA5 wind direction carried onto ``index``, held within each hour.""" + hourly = era5_df[ERA5_WD_COL] + return hourly.reindex(hourly.index.union(index)).ffill(limit=6).reindex(index) + + +def run_cell( + *, + mode: Literal["prepost", "toggle"], + faulted: bool, + northing: bool, + scada_df: pd.DataFrame, + era5_df: pd.DataFrame, + out_dir: Path, + include_power_model: bool = True, +) -> CampaignResult: + """Run one cell of the 2x2 and return its result.""" + campaign = fixture_campaign(mode, faulted=faulted, northing=northing, coords=_coords(FIXTURE_TURBINES)) + dataset = campaign.generate(scada_df) + spec = campaign.spec() + index = pd.DatetimeIndex(dataset.synthetic_df.index.unique()).sort_values() + runner = CampaignRunner( + spec, + dataset, + build_methods=lambda wtg: carried_forward_methods( + spec, + out_dir=out_dir / wtg, + era5_hourly_df=era5_df if include_power_model else None, + include_power_model=include_power_model, + # the runner norths below, so the northed column the feature needs is always present + direction_feature=True, + ), + era5_wd=_era5_direction(era5_df, index), + ) + return runner.run() + + +def run_fixture( + *, + modes: Sequence[str] = ("prepost", "toggle"), + include_power_model: bool = True, + out_root: str | Path | None = None, +) -> pd.DataFrame: + """Run the whole 2x2 for each mode and return the bites/fixed table. + + :return: one row per ``(mode, method, arm)`` with the estimate, truth and signed error + """ + root = Path(out_root) if out_root is not None else default_output_root() + run_dir = root / f"{pd.Timestamp.now():%Y%m%d_%H%M%S}" + run_dir.mkdir(parents=True, exist_ok=True) + + era5_df = build_hot_v0_context(wtg_names=list(FIXTURE_TURBINES)).reanalysis_datasets[0].data + rows: list[dict[str, object]] = [] + for mode in modes: + period = analysis_period(mode) # type: ignore[arg-type] + logger.info("loading Hill of Towie SCADA %s..%s for %s", *period, list(FIXTURE_TURBINES)) + scada_df, _ = load_hot_scada( + start_dt=period[0], + end_dt_excl=period[1], + wtg_numbers=[int(w[1:]) for w in FIXTURE_TURBINES], + wtg_names=list(FIXTURE_TURBINES), + ) + for faulted in (False, True): + for northing in (False, True): + arm = f"{'faulted' if faulted else 'clean'}/{'northed' if northing else 'raw'}" + logger.info("running %s %s", mode, arm) + result = run_cell( + mode=mode, # type: ignore[arg-type] + faulted=faulted, + northing=northing, + scada_df=scada_df, + era5_df=era5_df, + out_dir=run_dir / f"{mode}_{'faulted' if faulted else 'clean'}_{'northed' if northing else 'raw'}", + include_power_model=include_power_model, + ) + rows.extend( + { + "mode": mode, + "method": row.method, + "faulted": faulted, + "northing": northing, + "arm": arm, + "estimate": row.estimate, + "truth": row.truth, + "signed_error": row.signed_error, + } + for row in result.farm.itertuples() + ) + table = pd.DataFrame(rows) + table.to_csv(run_dir / "bites_and_fixed.csv", index=False) + verdicts = verdict_table(table) + verdicts.to_csv(run_dir / "verdicts.csv", index=False) + logger.info("wrote the fixture results to %s", run_dir) + return table + + +def verdict_table(table: pd.DataFrame) -> pd.DataFrame: + """Turn the 2x2 of errors into the bites / fixed / no-harm verdicts, per mode and method. + + ``bites`` is the degradation the fault causes with no northing; ``fixed`` is what the fault + still costs once northing runs; ``no_harm`` is what northing costs on clean data. All in + percentage points of energy-ratio error. + """ + rows = [] + for (mode, method), group in table.groupby(["mode", "method"]): + cell = {(bool(r.faulted), bool(r.northing)): abs(float(r.signed_error)) * 100 for r in group.itertuples()} + if len(cell) != 4: # noqa: PLR2004 - the 2x2 needs all four arms + continue + reference = cell[False, False] + rows.append( + { + "mode": mode, + "method": method, + "clean_raw_pp": reference, + "faulted_raw_pp": cell[True, False], + "clean_northed_pp": cell[False, True], + "faulted_northed_pp": cell[True, True], + "bites_pp": cell[True, False] - reference, + "fixed_pp": cell[True, True] - reference, + "no_harm_pp": cell[False, True] - reference, + } + ) + out = pd.DataFrame(rows) + if not out.empty: + out["bites"] = out["bites_pp"] >= 1.0 + out["fixed"] = out["fixed_pp"] <= 0.25 # noqa: PLR2004 - the spec's acceptance threshold + out["no_harm"] = out["no_harm_pp"] <= 0.25 # noqa: PLR2004 - the spec's acceptance threshold + return out + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + results = run_fixture() + summary = verdict_table(results) + print(summary.to_string(index=False)) # noqa: T201 - a driver's whole point is its printed summary + print(f"\n{np.count_nonzero(summary['bites'])} of {len(summary)} cases bite") # noqa: T201 diff --git a/benchmarking/campaigns/runner.py b/benchmarking/campaigns/runner.py index 12edc151..1ff3182f 100644 --- a/benchmarking/campaigns/runner.py +++ b/benchmarking/campaigns/runner.py @@ -9,16 +9,19 @@ import pandas as pd from benchmarking.campaigns.context import context_for +from benchmarking.campaigns.northing import DEFAULT_NORTHING_ROLES, north_campaign_scada from benchmarking.harness import CampaignWindow, Replicate, score_one, truth_mask from wind_up import TurbineUplift, farm_uplift +from wind_up.northing import DEFAULT_NORTHING if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Sequence from benchmarking.campaigns.declaration import CampaignSpec from benchmarking.harness import Method, MethodInput, MethodOutput from benchmarking.synthetic import SyntheticDataset from wind_up import FarmUplift + from wind_up.northing import NorthingSettings # The farm table's columns, named here so a campaign with nothing to aggregate still returns a @@ -67,6 +70,12 @@ class CampaignRunner: :param spec: the public campaign facts; methods see nothing else :param dataset: the generated dataset, whose ``original_df`` supplies the truth :param build_methods: given an upgraded turbine's name, the methods to run for it + :param era5_wd: reanalysis wind direction covering the campaign. Supplying it turns on the + shared northing step, which writes a ``northed_`` companion for each direction role + upstream of every method. Required when ``spec.north_offsets`` is ``None`` and northing + is wanted; without it the step is skipped and methods see no northed column. + :param northing_roles: the direction roles the shared step corrects + :param northing_settings: how the shared step's changepoint search is bounded """ def __init__( @@ -75,11 +84,17 @@ def __init__( dataset: SyntheticDataset, *, build_methods: Callable[[str], list[Method]], + era5_wd: pd.Series | None = None, + northing_roles: Sequence[str] = DEFAULT_NORTHING_ROLES, + northing_settings: NorthingSettings = DEFAULT_NORTHING, ) -> None: """Store the campaign, its data and the per-turbine method factory.""" self._spec = spec self._dataset = dataset self._build_methods = build_methods + self._era5_wd = era5_wd + self._northing_roles = tuple(northing_roles) + self._northing_settings = northing_settings def run(self) -> CampaignResult: """Run every applicable method on every upgraded turbine and aggregate to one headline.""" @@ -174,15 +189,33 @@ def _farm_row( } def _visible_dataset(self) -> SyntheticDataset: - """Return the dataset cut to what a method may see: analysis period, usable turbines only.""" + """Return the dataset cut to what a method may see: analysis period, usable turbines only. + + The shared northing step runs here, so every method downstream inherits the north-calibrated + direction rather than each hand-rolling one. + """ synthetic = self._dataset.synthetic_df keep = self._visible_mask(synthetic) + visible = synthetic[keep] + if self._should_north(): + visible = north_campaign_scada( + visible, + spec=self._spec, + columns=self._dataset.columns, + era5_wd=self._era5_wd, + roles=self._northing_roles, + settings=self._northing_settings, + ) return replace( self._dataset, - synthetic_df=synthetic[keep], + synthetic_df=visible, original_df=self._dataset.original_df[self._visible_mask(self._dataset.original_df)], ) + def _should_north(self) -> bool: + """Whether the shared step can run: a declared table needs nothing, discovery needs ERA5.""" + return self._spec.north_offsets is not None or self._era5_wd is not None + def _visible_mask(self, frame: pd.DataFrame) -> np.ndarray: """Rows of ``frame`` inside the analysis period whose turbine may be used.""" spec = self._spec diff --git a/benchmarking/synthetic/__init__.py b/benchmarking/synthetic/__init__.py index 13753372..687d339f 100644 --- a/benchmarking/synthetic/__init__.py +++ b/benchmarking/synthetic/__init__.py @@ -7,6 +7,7 @@ from __future__ import annotations from benchmarking.synthetic.cp_core import HOT_CP_MODEL, CpCore, CpParams, cp_surface +from benchmarking.synthetic.faults import Fault, NorthingStep from benchmarking.synthetic.generator import SyntheticDataset, ToggleSchedule, generate_dataset, treated_mask from benchmarking.synthetic.geometry import WakePair, bearing_deg, derive_wake_steering_pairs, distance_m, wrap180 from benchmarking.synthetic.ground_truth import UpliftResult, true_farm_uplift, true_net_uplift, true_uplift @@ -50,6 +51,8 @@ "ConstantCpChange", "CpCore", "CpParams", + "Fault", + "NorthingStep", "RatedPowerChange", "SyntheticDataset", "ToggleSchedule", diff --git a/benchmarking/synthetic/faults.py b/benchmarking/synthetic/faults.py new file mode 100644 index 00000000..5286eae1 --- /dev/null +++ b/benchmarking/synthetic/faults.py @@ -0,0 +1,95 @@ +"""Injected data faults: the pathologies real SCADA carries, with known ground truth. + +A fault corrupts what a method **measures**, never what the turbine **produced**. It is applied +to the synthetic frame after the upgrades, leaving ``original_df`` untouched, so the true uplift +is unchanged by construction and any movement in an estimate is the fault's doing. + +That is what separates a fault from an upgrade: an upgrade changes power and moves the truth; a +fault changes a reading and moves only the estimate. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +import numpy as np + +if TYPE_CHECKING: + import pandas as pd + + from benchmarking.synthetic.schema import ColumnSchema + + +@runtime_checkable +class Fault(Protocol): + """A measurement corruption applied to the synthetic frame.""" + + @property + def description(self) -> dict: + """Serialisable provenance recorded in the dataset's run metadata.""" + ... + + def __call__(self, synthetic_df: pd.DataFrame, *, columns: ColumnSchema) -> pd.DataFrame: + """Return ``synthetic_df`` with this fault injected.""" + ... + + +@dataclass(frozen=True) +class NorthingStep: + """A step change in one turbine's reported direction, from ``at`` to the end of the record. + + The signature of a recalibration or a sensor swap: the turbine's north reference moves and + nothing else does. Power is untouched, so ground truth is unaffected. + + :param turbine: the turbine whose direction reading steps + :param at: when the step happens; rows from here on carry the offset + :param offset_deg: degrees added to the reading (wrapped to 0-360) + :param role: the :class:`~benchmarking.synthetic.schema.ColumnSchema` direction role to + corrupt; the nacelle position by default + """ + + turbine: str + at: pd.Timestamp + offset_deg: float + role: str = "nacelle_position" + + @property + def description(self) -> dict: + """Return serialisable provenance describing this fault.""" + return { + "kind": "northing_step", + "turbine": self.turbine, + "at": str(self.at), + "offset_deg": float(self.offset_deg), + } + + def __call__(self, synthetic_df: pd.DataFrame, *, columns: ColumnSchema) -> pd.DataFrame: + """Return ``synthetic_df`` with ``turbine``'s direction stepped by ``offset_deg`` from ``at``.""" + columns.require_roles([self.role]) + column = getattr(columns, self.role) + if column not in synthetic_df.columns: + msg = ( + f"cannot inject a northing step: the {self.role} column {column!r} is not in the frame. " + f"Columns present: {sorted(synthetic_df.columns)}" + ) + raise ValueError(msg) + is_turbine = (synthetic_df[columns.turbine] == self.turbine).to_numpy() + if not is_turbine.any(): + present = sorted({str(t) for t in synthetic_df[columns.turbine].unique()}) + msg = f"cannot inject a northing step into {self.turbine!r}: it is not in the frame, which has {present}" + raise ValueError(msg) + + synthetic_df = synthetic_df.copy() + stepped = is_turbine & np.asarray(synthetic_df.index >= self.at) + values = synthetic_df[column].to_numpy(dtype=float) + values[stepped] = (values[stepped] + self.offset_deg) % 360.0 + synthetic_df[column] = values + return synthetic_df + + +def apply_faults(synthetic_df: pd.DataFrame, faults: list, *, columns: ColumnSchema) -> pd.DataFrame: + """Apply every fault to ``synthetic_df`` in order, returning the corrupted frame.""" + for fault in faults: + synthetic_df = fault(synthetic_df, columns=columns) + return synthetic_df diff --git a/benchmarking/synthetic/generator.py b/benchmarking/synthetic/generator.py index 93b4f34c..8e511f74 100644 --- a/benchmarking/synthetic/generator.py +++ b/benchmarking/synthetic/generator.py @@ -16,6 +16,7 @@ import numpy as np from benchmarking.synthetic.cp_core import HOT_CP_MODEL, CpCore, CpParams +from benchmarking.synthetic.faults import apply_faults from benchmarking.synthetic.ground_truth import UpliftResult, true_farm_uplift, true_net_uplift, true_uplift from benchmarking.synthetic.sources.hill_of_towie import HOT_COLUMNS from benchmarking.synthetic.upgrades import apply_upgrades @@ -164,6 +165,7 @@ def generate_dataset( upgrades: list, mode: Literal["prepost", "toggle"], upgrade_timing: pd.Timestamp | ToggleSchedule, + faults: list | None = None, cp_params: CpParams = HOT_CP_MODEL, rated_power_kw: float = 2300.0, columns: ColumnSchema = HOT_COLUMNS, @@ -176,6 +178,9 @@ def generate_dataset( :param upgrades: upgrade callables applied to each test turbine's treated rows :param mode: ``"prepost"`` (changeover date) or ``"toggle"`` :param upgrade_timing: changeover timestamp (prepost) or toggle schedule + :param faults: measurement corruptions injected after the upgrades, into the synthetic frame + only. They change readings rather than power, so the ground truth derived against + ``original_df`` is unaffected -- see :mod:`benchmarking.synthetic.faults`. :param cp_params: Cp surface parameters for the test turbines :param rated_power_kw: baseline rated power for the test turbines :param columns: the source-native column schema ``scada_df`` is keyed by @@ -208,11 +213,16 @@ def generate_dataset( for col in modified_columns: synthetic_df.loc[mask, col] = modified[col].to_numpy() + faults = list(faults or []) + if faults: + synthetic_df = apply_faults(synthetic_df, faults, columns=columns) + run_metadata = { "test_wtgs": list(test_wtgs), "mode": mode, "upgrade_timing": str(upgrade_timing), "upgrades": [u.description for u in upgrades], + "faults": [f.description for f in faults], "rated_power_kw": rated_power_kw, "cp_params": asdict(cp_params), "seed": seed, diff --git a/docs/superpowers/specs/2026-09-02-r1-northing-design.md b/docs/superpowers/specs/2026-09-02-r1-northing-design.md new file mode 100644 index 00000000..8b4e8467 --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-r1-northing-design.md @@ -0,0 +1,715 @@ +# Design — R1: northing errors (shared fix) + +**Date:** 2026-09-02 +**Status:** approved design +**Issue:** `docs/v1/issues_campaigns.md` § R1 +**Extends:** `2026-08-28-robustness-failure-modes-design.md` (R-series ground rules), +`2026-09-01-c2-campaign-context-seam-design.md` (the seam decisions R1 fills in) +**Branch of work:** `v1-R1`, developed off `v1` + +## Problem + +A turbine's direction reference carries **step changes** in its north calibration — +a recalibration, a sensor swap, a controller replacement. wind-up must recover a known +uplift regardless. + +Two things stand in the way today. + +**The estimator is too slow to use.** `src/wind_up_v0/optimize_northing.py` (762 lines) +hill-climbs over a hand-rolled move set — shift a changepoint forward, shift it back, +add *n* changepoints via `ruptures.BottomUp` with a custom circular L1 cost — rescoring +the whole turbine on every move, with a step size that decays and re-inflates through +a `1/(DECAY_FRACTION ** (pi * (tries_left + 1))) % 10` schedule. It is slow enough that +it is switched off in practice: both `examples/` set `optimize_northing_corrections=False` +and use pre-computed tables, and the benchmarking layer reads a vendored +`optimized_northing_corrections.yaml` (the result of a prior run of `optimize_northing.py`). + +**No v1 method can see a northing error.** `benchmarking/baselines/power_model/features.py` +builds features from reference active power, availability and ERA5 — no turbine direction +signal of any kind. A step injected into `YawAngleMean` is invisible to it, so the +R-series' phase 1 ("the fault bites") is unreachable until the feature exists. + +## Scope + +All four parts land together, in this order: + +1. a fast, `ruptures`-free northing estimator in `src/wind_up/northing.py`; +2. a shared northing step in the campaign runner, upstream of every method, plus the + `power_model` reference-direction feature that makes a northing error visible; +3. a fault injector and the tiny fixture that proves *bites* then *fixed*; +4. `src/wind_up_v0/optimize_northing.py` reduced to a thin adapter over the new core, + with its tests ported and `ruptures` dropped from `pyproject.toml`. + +`naive_ratio` and `toggle_specialist` use no direction signal and are out of scope. + +## Key decisions + +1. **Exact dynamic programming, not a numerical optimiser.** Locating step changes is + combinatorial; a continuous optimiser has nothing to descend, which is the fragility + the current hill-climb exhibits. Given the segments, each offset is closed-form (a + circular median). So the estimator contains no optimiser at all. + +2. **Aggregate before searching.** The residual is piecewise-constant plus noise, so a + daily circular median loses nothing a changepoint search needs and turns 105k rows + (2 years at 10 minutes) into ~730 points. This is what makes the search cheap; local + refinement recovers sub-day changepoint timing afterwards. + +3. **The core is frame-agnostic and device-neutral.** It takes an index, a direction + array, a reference-direction array and a caller-supplied `usable` mask. Turbine-specific + logic (generating above 5% of rated, not in downtime) lives in a helper, not the core, + so masts and LiDARs are a mask away rather than a rewrite. + + **`usable` is also how wake steering is handled.** A steering turbine is deliberately + yawed off the wind, so a steered period looks exactly like a northing offset that appears + and disappears on the steering schedule. Excluding those rows via the mask is the whole + fix, and it needs no change to the core — which is a second reason to keep mask + construction outside it. C5 supplies the steered-period mask; R1 only has to not + foreclose it. + +4. **Knobs are in physical units.** `min_step_deg` (the smallest step worth reporting) + and changepoints-per-year, not sample counts or pruning constants. See the prior-art + review below for why this is worth insisting on. + +5. **The returned table is always absolute** — offsets relative to the **raw** field, + never "further corrections to an already-corrected field". This is what makes a supplied + table and an estimated one directly comparable, and repeated runs composable. See + *Designed for, not implemented*. + +6. **Two passes are preserved.** Pass 1 norths each turbine to reanalysis wind direction; + the northed yaws give a farm direction; pass 2 norths to that. Pass 2 is far less noisy, + but pass 1 is what anchors the farm in absolute terms — without it a farm that is + uniformly 180° wrong looks perfectly self-consistent. + +7. **The fault is a measurement corruption, not an upgrade.** It changes direction only, + never power, so ground truth is untouched by construction. + +8. **Success is invariance**, per the R-series ground rules: the target is + `power_model`-under-fault ≈ `power_model`-clean, not a race against v0. + +9. **How small a step may be attributed depends on the reference.** Reanalysis is a modelled + direction carrying its own drift, and a shift in it is indistinguishable from every turbine + shifting at once — so against reanalysis only steps above `REANALYSIS_MIN_STEP_DEG` (10°) + are attributable, whatever tier the caller chose (`against_reanalysis(effort)`). A farm + consensus shares that common-mode error, so against one a residual step really is that + turbine's and the caller's `min_step_deg` applies. Where the farm reference has fallen back + to reanalysis (fewer than three turbines) the second pass stays conservative too. This is + what v0 was already expressing as `best_score_margin=0.5` on its reanalysis pass. Not + foreseen in design — forced by the measurement in *Evidence* below. + +## Evidence: reference-side drift is real and had to be designed for + +Measured on the Homer July-2023 fixture while porting the v0 tests. Against reanalysis, both +turbines appeared to step on **2023-07-12 within 40 minutes of each other** -- T01 by 3.8 +degrees, T02 by 5.0. Two independent sensor recalibrations on the same afternoon is not +credible, so the shared signal was isolated by comparing the turbines with **each other** +instead of with reanalysis: + +| comparison | before 07-12 | after 07-12 | step | +|---|---|---|---| +| T01 - reanalysis | 55.80 | 52.00 | **-3.80** | +| T02 - reanalysis | -118.40 | -123.30 | **-4.90** | +| T01 - T02 (no reanalysis) | 174.00 | 175.00 | **+1.00** | + +About 4 degrees of the apparent step is common-mode: it is in the reanalysis reference, not in +the turbines, whose relative alignment barely moved. An estimator that attributes it to the +turbines produces two spurious changepoints and shifts the northing by ~1.7 degrees. + +`REANALYSIS_MIN_STEP_DEG = 10` is sized from this: about twice the measured common-mode drift, +and far below a real sensor swap (the ported test injects 30-degree steps, and a north +recalibration is a large move by nature). It is a floor on the threshold, not a new tier, so a +caller asking for `thorough` still gets 1-degree resolution against the farm consensus. + +The measurement agrees with the physics: **reanalysis is not accurate to better than ~10° as a +representation of the wind direction at a specific turbine's hub height.** It is a coarse-grid +modelled field, not a measurement at the rotor. So attributing a sub-10° step to a turbine on +reanalysis evidence alone is asking the reference for precision it does not have, whatever any +one fixture shows. + +This is the same failure HOGER's >50% pairwise-consensus vote addresses, reached from the other +direction -- which is why pairwise consensus stays the recorded next step rather than a +speculative one. + +## Evidence: site veer, and why a threshold alone cannot fix it + +Comparing the first implementation's north tables against the vendored Hill of Towie table (the +old optimizer's output, and the only "old" answer we need — it need not be re-run) exposed a +second, larger problem than the reanalysis drift above. + +**84 changepoints in 2017–18 against the old table's 7.** T06 alone got 12, sitting exactly on +its `6/year × 2` budget ceiling. Its offsets oscillated between ≈ −6° and ≈ −13° and **netted +0.38° across all 12 steps** — ending where they began. A recalibration is permanent; this was an +excursion being approximated by a square wave. + +The cause is **site veer**: the wind direction genuinely differs from turbine to turbine across a +site, varying with bulk direction, atmospheric stability and wind speed. So a turbine's residual +against the farm median has a level that depends on *which directions the wind blew from*, and a +shift in the direction mix moves that level with nothing at the turbine having changed. The +spurious-jump count tracks the veer amplitude exactly, with the cut falling on +`balanced`'s 3° threshold: + +| turbine | monthly-residual range | spurious jumps | +|---|---|---| +| T06 | 4.7° | 12 | +| T09 | 3.5° | 10 | +| T15 | 3.4° | 8 | +| T07 | 2.0° | **0** | +| T11 | 1.3° | **0** | + +Raising `min_step_deg` was measured and rejected as the fix. It works, but only by trading away +real detections — at 10° the worst northing error jumps to 7.9° (a genuine step between 7° and +10° goes unfound), and veer amplitude is turbine-specific, so one global threshold is either too +loose for T06 or too tight for T11. **The tiers were left at the user's original 5 / 3 / 1°** and +the cause attacked instead, by two mechanisms. + +### 1. Veer normalisation (`veer_normalised`) + +Subtract each direction sector's own whole-record median from the residual before searching. A +genuine north offset shifts every sector alike and survives; a change in the direction mix cannot +move the level at all. 30° sectors by default (20° measured no better). + +Two details make it correct rather than circular: + +- **Detection only.** Segment offsets are estimated from the *raw* residual, so the correction + stays absolute and the 1°-accuracy goal is untouched. +- **De-step first.** Measuring sector levels on a record that contains large steps lets the steps + leak into the veer signature, and uneven direction sampling between segments then distorts the + very steps being looked for — this broke the ported Homer changepoint test outright. So a first + pass detects on the raw residual purely to remove the step structure, the veer signature is + measured on that de-stepped residual, and the real search runs on the normalised one. + +### 2. Ironing out self-cancelling excursions (`_prune_transient_steps`) + +After detection, drop changepoints that do not *persistently* move the level: for each one, +compare the duration-weighted level of everything before it with everything after. Veer wanders +away and back, so either side sits at the same place; a recalibration leaves the level moved. + +**With an amplitude gate, which is essential.** A first cut without one pruned by persistence +alone and sent the worst northing error to 17°, because real recalibrations *do* sometimes +reverse: the vendored table has T16 stepping 98°, 9°, 7°, 89° for a net of only +11.4°. So a step +above `max_transient_step_deg` (10°) is never ironed out — its size is the evidence it happened. +Below that, the same table shows T11 (four steps of 2–9°, net +0.3°) and T10 (8.2°/9.6°, net ++1.5°), which look exactly like the veer the filter is meant to remove. + +It is a threshold rule, not an oracle: an oscillation biased enough that its halves sit at +genuinely different levels keeps the changepoints carrying that difference (a unit test pins +this, so the limit is documented rather than discovered later). + +### Measured effect + +Farm-scale, Hill of Towie, 21 turbines, 2017–18, at the unchanged `balanced` 3° threshold: + +| configuration | jumps | turbines | worst err | +|---|---|---|---| +| first implementation | 84 | 19 | 3.665 | +| + veer normalisation | 51 | 11 | 3.471 | +| **+ excursion pruning (shipped)** | **8** | **4** | 5.125 | +| *old v0 (vendored table)* | *7* | *3* | *5.121* | + +**Mean error is deliberately not in that table.** It is *best* on the 84-jump row, so it rewards +over-detection and cannot discriminate — more free parameters always fit better. Jump count +against the real rate (~0.3/turbine/year, from the vendored table's 49 entries over 8 years and +21 turbines) and worst-case error are the honest measures. + +The decisive result is not the totals but **which** turbines. The shipped estimator finds +`{T01: 2, T05: 2, T13: 1, T16: 3}`; the vendored v0 table's 2017–18 changepoints are +`{T01: 2, T05: 2, T16: 3}`. It **independently rediscovers v0's changepoints exactly** — same +turbines, same counts — and adds one on T13, whose worst-case error improves 3.85° → 3.67°. + +Honest attribution: **the excursion pruning does most of the work**; direction binning contributes +5–10%, and is kept because it is physically right and cheap, not because it carries the result. + +On the fixture the effect is starker still: the clean arms now discover **0** changepoints and the +faulted arms exactly **1** — the injected fault and nothing else, where the first implementation +found 7–11 spurious ones per run. + +## Prior art + +**HOGER** (Homogenization Of GEneral Regressions), Engie + CENER, merged into FLASC as +`flasc/data_processing/northing_offset_change_hoger.py` +([PR #240](https://github.com/NatLabRockies/flasc/pull/240)), is the closest published work. + +| | HOGER | R1 | +|---|---|---| +| reference | pairwise turbine-vs-every-other differences (`wrap_180`) | two-pass: reanalysis → farm-median yaw | +| detection | `DecisionTreeRegressor` on time → difference; splits are knots | exact DP on daily circular medians | +| optimality | greedy (CART), `max_depth=4` caps knots at 15 | globally optimal for a given K | +| consensus | keeps a jump only if it appears in >50% of that turbine's pairwise comparisons | robust circular median across the farm | +| tuning | `min_samples_split=1000`, `min_samples_leaf=500`, `ccp_alpha=0.09` | `min_step_deg`, changepoints/year, `min_segment` | +| absolute anchor | none — homogenises only | reanalysis pass | + +Three conclusions: + +- **HOGER is purely differential.** It makes turbines agree with each other but cannot + detect that they all agree on the wrong north. A farm uniformly 180° out is invisible + to it. That is the argument for keeping the reanalysis pass, and it is why the + 180°-wrong-farm unit test below is a first-class acceptance test rather than an edge case. +- **Physical knobs beat pruning constants.** `ccp_alpha=0.09` is not a quantity an analyst + can reason about. `min_step_deg=3` is. +- **The pairwise-consensus trick beats a farm median when N is small.** One jumping + turbine contaminates a 4-turbine median, and the R1 fixture is exactly 4 turbines. Recorded + as a named future option; the reanalysis pass is the anchor in the meantime. + +Background, not code: [Bromm et al., WES 2018](https://wes.copernicus.org/articles/3/395/2018/) +on detecting alignment changes from SCADA; [SkySpecs on north offset](https://skyspecs.com/blog/addressing-north-offset-in-wind-turbines-scada-data/). +OpenOA has no northing module. Nothing found combines globally-optimal circular +segmentation with an absolute anchor. + +## The estimator core — `src/wind_up/northing.py` + +### Public surface + +```python +@dataclass(frozen=True) +class NorthingEffort: + changepoints_per_year: float + min_step_deg: float + refine: bool + grid: pd.Timedelta = pd.Timedelta(days=1) + min_segment: pd.Timedelta = pd.Timedelta(days=7) + +FAST / BALANCED / THOROUGH: NorthingEffort + +def estimate_north_table( + index: pd.DatetimeIndex, + direction_deg: npt.NDArray[np.float64], + *, + reference_deg: npt.NDArray[np.float64], + usable: npt.NDArray[np.bool_], + effort: NorthingEffort | Literal["fast", "balanced", "thorough"] = "balanced", +) -> pd.DataFrame: # columns: timestamp, north_offset + +def apply_north_table( + index: pd.DatetimeIndex, + direction_deg: npt.NDArray[np.float64], + *, + north_table: pd.DataFrame, +) -> npt.NDArray[np.float64] # (direction + offset) % 360 + +def yaw_usable( + *, power: NDArray, downtime_s: NDArray, reference_deg: NDArray, + rated_power: float, timebase_s: int, +) -> npt.NDArray[np.bool_] +``` + +`estimate_north_table` works on any direction field. `yaw_usable` is the turbine mask +(the existing `add_ok_yaw_col` rule: reference present, power above 5% of rated, downtime +below a quarter of the timebase). Masts and LiDARs need a wind-speed-based mask instead; +documented as not yet wired up. + +`apply_north_table` is array-in/array-out so **one table can be applied to several fields +of the same device** — derive the correction from yaw position, apply it to yaw position +*and* a measured wind-direction channel. + +### Algorithm + +1. **Residual.** `d = circ_diff(direction_deg, reference_deg)` where `usable`, NaN elsewhere. +2. **Aggregate** `d` to `effort.grid` bins by per-bin circular median, carrying each bin's + count as a weight. ~730 points for two years. +3. **Prefix sums** of `w·sin(d)`, `w·cos(d)`, `w`. Any segment's weighted resultant-length + cost is then O(1): + `C(i,j) = W(i,j) − hypot(Σ w·sin, Σ w·cos)` — the loss minimised by the circular mean. +4. **Exact DP.** `best[k][j] = min_i best[k−1][i] + C(i,j)`, subject to `min_segment`, + with `K ≤ ceil(changepoints_per_year × years_of_data)`. Choose K by penalised total, + the per-changepoint penalty derived from `min_step_deg` (a step smaller than that is + not worth a changepoint). Vectorised per k; milliseconds at m ≈ 730. +5. **Refine** (when `effort.refine`): re-scan each changepoint at native resolution within + ±1 grid bin, same cost function. +6. **Offsets.** Per segment, `−circ_median(d)` over its native usable rows — robust, and + only K+1 of them. +7. **Iron out excursions.** Drop changepoints that do not persistently move the level and whose + own step is under `max_transient_step_deg` — site veer wandering away and back. +8. **Prune small steps.** Drop any remaining changepoint whose step is under `min_step_deg`, which + is what makes that knob mean what it says. + +Steps 1–5 run **twice**: once on the raw residual to locate the step structure, then again on the +veer-normalised residual (the veer signature measured on the de-stepped record). Offsets always +come from the raw residual, so the correction is absolute. + +Grid stays at one day for every tier: local refinement already recovers sub-day timing, so +a finer grid would quadruple the DP for nothing. `min_segment` prevents pathological +micro-splits. + +### Effort tiers + +There is **one setting**, not a menu — `NorthingSettings`, exposed as `DEFAULT_NORTHING`: + +| field | value | why | +|---|---|---| +| `changepoints_per_year` | 12 | a rate, so a longer record gets a larger budget | +| `min_changepoints` | 3 | a floor, so a short record can still hold several corrections | +| `min_step_deg` | 3° | the smallest step reported | +| `refine` | `True` | measured free (3.6s vs 3.7s on a farm-year) | +| `veer_sector_deg` | 30° | see *Evidence: site veer* | +| `max_transient_step_deg` | 10° | above this a step is never ironed out as wander | +| `grid` / `min_segment` | 1 day / 7 days | search resolution and shortest gap | + +**The effort tiers were built, measured and removed.** The knob was introduced to trade speed for +quality, and the trade turned out not to exist: across a 21-turbine, 2-year farm the whole +spread from the cheapest to the most thorough setting was **3.1 s to 5.2 s**, out of a ~40 s run +dominated by data handling rather than the search. Worse, the cheap tier was *lower quality for +no real saving* — its `1/year` budget missed a genuine changepoint and left a 7.9° worst-case +error against 5.1° for the default. A dial whose cheap end is worse and no faster is not a dial. + +`NorthingSettings` survives as an expert override, not a menu; the tier names and the string API +are gone. Nothing pretends to be a speed control. + +`max_changepoints = ceil(changepoints_per_year × years_of_data)`, so the budget scales with +record length rather than being a fixed count. `fast` is the R1 workhorse: one large +injected step is exactly the K≤1-per-year, no-refinement case. + +### Two-pass driver + +```python +def north_farm( + index: pd.DatetimeIndex, + *, + direction_deg: Mapping[str, npt.NDArray[np.float64]], # device -> direction, on ``index`` + usable: Mapping[str, npt.NDArray[np.bool_]], # device -> mask, on ``index`` + reanalysis_deg: npt.NDArray[np.float64], # on ``index`` + effort: NorthingEffort | str = "balanced", + min_devices_for_farm_reference: int = 3, +) -> dict[str, pd.DataFrame] # device -> absolute north table +``` + +Every device shares one `index`, which is what lets the farm reference be computed by +position. Pass 1 norths each device to `reanalysis_deg`. The northed directions give a farm +direction (circular median across devices, at least `min_devices_for_farm_reference` present +at a timestamp, else NaN). Pass 2 norths each device to that. Returns one absolute north +table per device. + +## Seam 1 — the shared step + +Runs in `CampaignRunner`, which holds the `CampaignSpec` (per the C2 decision that the +runner, not a method, owns this). It writes `columns.northed(role)` — `northed_YawAngleMean` +for `nacelle_position` — **alongside the untouched original**, so existing plots and +diagnostics keep meaning what they say. There is **no `northing_applied` flag**: the +column's presence is the state. + +The step takes a list of direction roles to correct (default `["nacelle_position"]`), +derives one table per turbine from yaw position, and writes `northed_` for each role. + +### `north_offsets`: supplied or not + +`CampaignSpec.north_offsets` becomes `list[...] | None`, defaulting to `None`. Two states: + +| value | meaning | +|---|---| +| `None` (default) | **auto-calculate.** The analyst supplied nothing; wind-up norths from the data. The usual case. | +| a list (possibly empty) | **apply exactly this, discover nothing.** `[]` is simply the case with no corrections to apply, so it needs no separate rule. | + +wind-up does **no checking** of a supplied table in R1 — it applies it and moves on. Checking +a prior and reporting confirmation-or-amendments is real future usage (see *Designed for, not +implemented*), but building it now would mean designing a disagreement threshold and a report +with no caller to validate them against. This mirrors what v0 already does with +`optimize_northing_corrections` versus `northing_corrections_utc`. + +Either way the step writes `northed_`, so downstream consumers find the column +regardless of which branch ran. + +**Consequence for the benchmark:** a campaign that supplies a table never exercises +discovery. The placebo currently loads the real Hill of Towie YAML, so it would +apply-as-supplied and silently stop testing the thing R1 builds. Campaigns meant to exercise +discovery — the R1 fixture above, and C3/C5 — pass `None` explicitly, a per-campaign choice +made visibly rather than a property of the type. + +C3/C5 drop their bespoke northing wiring in favour of this step. + +## Seam 2 — `power_model` sees direction + +`build_reference_features` gains each reference's northed direction, as `sin`/`cos` +companions (LightGBM cannot see that 359° ≈ 1°). Guarded: the method raises, naming the +missing column, if the shared step has not run. `check_reference_only` already blocks the +test turbine's own direction, which is the design-note §3 rule — northing does not make a +post-treatment signal safe. + +**Northed replaces raw, never both.** Direction features come only from northed columns; a +raw direction offered in `extra_cols` when a northed counterpart exists is dropped, with a +log line. + +**It is opt-in, `direction_feature=False` by default — an open decision, not the end state.** +The shared northing step runs in `CampaignRunner`, so the *campaign* path has a northed column +but the *study* path (`build_replicates` → `score_one`, which drives both frozen benchmarks) +does not. Turning the feature on by default would make `power_model` raise on every study +driver. So the flag ships off, campaign method factories turn it on, and two things remain to +decide: + +1. whether the study path should also north (which means the step moving somewhere both paths + share, rather than living only in the runner), and +2. flipping the default and regenerating `study_power_model_compare_baseline.json`. + +Until (1) lands, R1's bites/fixed evidence comes from the campaign path only. That is enough +for the fixture, but it means the frozen benchmark does **not** yet move — contrary to what +this design assumed. + +## Seam 3 — v0 adapter + +`auto_northing_corrections(wf_df, *, cfg, plot_cfg)` keeps its signature and its two-pass +shape. It loops turbines, builds the four core arguments from `RAW_YAWDIR_COL`, +`REANALYSIS_WD_COL` and `WINDFARM_YAWDIR_COL`, and calls the core. v0's own +supplied-versus-discovered switch is unchanged: `cfg.northing_corrections_utc` is applied by +`apply_northing_corrections` as it is today, and `auto_northing_corrections` is what runs +when the analyst asked for discovery. + +Deleted: `CostCircularL1`, `_northing_score`, the move generator, the hill-climb, +`_calc_max_changepoints_to_add`, and the `ruptures` import. `ruptures` then leaves +`pyproject.toml` (dependency and `mypy` override). + +`northing.py`'s `apply_northing_corrections`, `add_wf_yawdir` and `check_wtg_northing` are +unchanged, as are the northing plots. + +This introduces a `wind_up_v0` → `wind_up` import, so the releasable v1 package does not +depend on the legacy one, and W2 promotes the module with no second move. + +**`circular_math` moves too.** The core needs `circ_diff`, `circ_median` and +`rolling_circ_median_approx`, which today live in `src/wind_up_v0/circular_math.py` — v1 +importing them from v0 would be exactly the dependency direction this decision avoids. So +the module moves to `src/wind_up/circular_math.py` and `src/wind_up_v0/circular_math.py` +becomes a re-export, leaving the seven v0 importers and four test modules untouched. + +**Blast radius is small and was verified:** `auto_northing_corrections` is reached only when +`optimize_northing_corrections=True`; both `examples/` set it `False` and use pre-computed +tables, and `hot_context` reads the vendored YAML. No frozen example or benchmark number +moves from this swap. + +**v0 stays verified end-to-end** by re-running the SMARTEOLE and WeDoWind examples. Where a +northing table is supplied the results must be **identical** (that path does not touch the +estimator at all). Where auto-northing runs they need only be **similar** — a different +optimiser finding a slightly different table is the expected outcome, not a regression. Note +that both examples ship with `optimize_northing_corrections=False`, so the auto-northing arm +has to be run with the flag deliberately flipped; it is not exercised by default. + +## The fault and the fixture + +### Fault + +`NorthingStep(turbine, at, offset_deg)` adds `offset_deg` to a turbine's reported +`nacelle_position` from `at`. It changes no power, so `true_uplift` is untouched by +construction. + +This earns a `faults: list = []` field on `SyntheticCampaign` — private ground truth, like +`upgrades` — applied after upgrade injection and to `synthetic_df` only. `CampaignSpec` +never sees it, and the fixture leaves `north_offsets=None` so the step must discover the +step change rather than be told about it: an analyst does not know it happened. The protocol +stays minimal so R2–R4 inherit it: `__call__(synthetic_df, *, columns) -> pd.DataFrame` plus +a `description` for run metadata. + +### Calibrating the fault + +Damage is not monotonic in offset size. Two levers matter more than magnitude: + +- **Timing.** Worst when the step coincides with the changeover in prepost, or falls in the + exact middle of a toggle campaign — that is when the corruption aligns with the contrast + the method is measuring. +- **Where the offset lands.** What matters is how much the power-ratio-versus-direction + shape changes, so a **30° offset can be more damaging than 180°** if it moves a crucial + wake onto a well-populated direction sector. + +So calibration sweeps timing and offset rather than winding magnitude up until something +breaks, and the chosen fault is justified by which sector it moves the wake into. + +### Fault target + +Both v0 and `power_model` key on the **reference** turbine's direction — `main_analysis.py` +sets `ref_wd_col = "ref_YawAngleMean"`, which feeds detrending, the waking scenarios, the +`ref_wd_filter` and the pp binning (`test_wd_col` appears only in a pre/post sanity check); +and `power_model` is barred from the test turbine's own direction by §3. So there is one +fault target — a reference — and one row per mode in the bites table. + +### Fixture + +`benchmarking/campaigns/northing_fixture.py`: **T06** plus its three nearest stable +neighbours, over a 12-month 2017 baseline into 2018. + +- T06 is the measured best fixture turbine (`power_model` mean |err| 0.34%, swing 0.72pp + across the placebo window sweep) and 12mo→2018 is the best-ranked window. +- **T05 is excluded** despite being T06's natural best reference: it carries real northing + steps in 2017–18, so injecting on top of them would muddy attribution. +- Injected uplift is `ws_dependent_cp` (+10% Cp below 5 m/s fading to 0 by 12 m/s) — the + AeroUp shape — so truth is non-zero and we measure error, not placebo drift. +- Declared in **both modes**: prepost changing over 2018-01-01, toggle in 50-minute blocks. + +### Natural-case probe (up front) + +Before any injection: run v0 on T06 prepost 2017→2018 with and without northing correction, +using T05 as reference, to size the naturally occurring instance of this failure mode. This +is a sighting shot that calibrates how large an injected step needs to be to be realistic. + +## Acceptance + +### Per mode, per method (`power_model`, `v0`) — a 2×2, not a pair + +| | northing off | northing on | +|---|---|---| +| **clean** | reference error | must be no worse — *no harm* | +| **faulted** | must be significantly worse — **bites** | must return to ≈ clean — **fixed** | + +The *no harm* cell is the one most easily skipped and the one that would sink C3 if it were +wrong. Where the fault does not bite in toggle (cancellation across on/off blocks), that is +**recorded explicitly** as "no mitigation needed there" — determined empirically, never +assumed. Fault magnitude is calibrated until it bites, per the R-series ground rules. + +Concrete thresholds, so the table has pass/fail rather than adjectives, with `e` the signed +error against the fixture's known truth: + +- **bites**: `|e(faulted, off)| − |e(clean, off)| ≥ 1.0 pp`. T06's `power_model` swing across + the placebo window sweep was 0.72 pp, so a 1 pp degradation is outside its natural + window-to-window scatter and cannot be luck. +- **fixed**: `|e(faulted, on)| − |e(clean, off)| ≤ 0.25 pp`, i.e. the fault's residual damage + is within a third of that natural scatter. +- **no harm**: `|e(clean, on)| − |e(clean, off)| ≤ 0.25 pp`. + +These are the acceptance thresholds; if the natural-case probe or the clean re-baseline +(which changes when `power_model` gains the direction feature) shows T06's scatter is +materially different from 0.72 pp, the thresholds are re-derived from the measured scatter +and the change recorded — they are not loosened to make a run pass. + +### Fixture results (measured) + +T06 + T15/T10/T08, 12 months of 2017 baseline into 2018, `ws_dependent_cp` uplift injected, a +40° `NorthingStep` on T15 at the changeover (prepost) / mid-campaign (toggle). Errors in +percentage points of energy ratio: + +| mode | method | clean/raw | faulted/raw | clean/northed | faulted/northed | bites | fixed | no harm | +|---|---|---|---|---|---|---|---|---| +| prepost | `power_model` | 0.451 | 1.782 | 0.399 | 0.534 | **+1.331 ✓** | **+0.083 ✓** | **−0.052 ✓** | +| prepost | `naive_ratio` | 5.892 | 5.892 | 5.892 | 5.892 | 0.000 | — | — | +| toggle | `power_model` | 0.114 | 0.089 | 0.040 | 0.072 | −0.025 ✗ | +(−0.042) ✓ | −0.075 ✓ | +| toggle | `naive_ratio` | 0.014 | 0.014 | 0.014 | 0.014 | 0.000 | — | — | +| toggle | `toggle_specialist` | 0.014 | 0.014 | 0.014 | 0.014 | 0.000 | — | — | + +**Prepost: the fault bites and the shared step fixes it.** 1.331 pp of damage against the +1.0 pp threshold, closed to 0.083 pp against the 0.25 pp threshold. The threshold was derived +from T06's 0.72 pp placebo swing *before* this was run, and the clean error came out at 0.451 pp +— consistent, so the bar was not set to fit the answer. + +**Toggle: the fault does not bite** (−0.025 pp), so no mitigation is needed there. This is the +cancellation the R-series design anticipated: with on/off blocks interleaved at 50 minutes, a +corruption present in both halves of the contrast largely cancels. **Recorded empirically, as +the ground rules require — not assumed.** + +**`naive_ratio` and `toggle_specialist` are unmoved to the last digit** in all four arms, which +confirms the scoping decision: they read no direction signal, so northing is neither a risk nor +a benefit to them. + +Two further observations: + +- **The direction feature is doing real work.** In the power model's gain ranking the six + `northed_wtc_NacelPos_mean_{sin,cos} @ {T08,T10,T15}` features come in immediately after the + three reference active-power columns and the T10 power minimum — ahead of every ERA5 column. + Without them the fault could not bite at all, which is why Seam 2 is a prerequisite rather + than an enhancement. +- **Northing helps on clean data too** — the *no harm* cell is negative in both modes (0.451 → + 0.399 prepost, 0.114 → 0.040 toggle). The step discovers 7–11 changepoints across the four + turbines even in the clean arm, where the vendored table (an old-optimizer product) says there + are none. Given the farm-scale result, the likely reading is that these are real corrections + the hill-climb missed rather than false positives — but it is inferred from the error moving + the right way, not directly confirmed, and the small-N farm consensus stays a recorded risk. + +### v0 swap — three pieces of evidence + +1. **Ported tests.** `tests/test_optimize_northing.py`'s three `wind_direction_offset` cases + and its injected-changepoint second half pass at the same or tighter tolerances (currently + `abs=1.0` / `abs=1.5` degrees). +2. **The 180°-wrong farm.** A new unit test where every turbine is uniformly 180° out, + proving the reanalysis pass is load-bearing and that pass 2 alone is blind to a + common-mode offset. This is the case HOGER cannot address. +3. **Farm-scale real-data comparison.** Re-derive Hill of Towie's northing with both + implementations and compare turbine-by-turbine, with runtime measured for both. + +### Measured results + +**Homer, July 2023, 2 turbines** (the ported v0 test): the new estimator reproduces the old one +**exactly** — identical median yaw and identical max northing error on all three +`wind_direction_offset` cases. Runtime is a wash at this size (0.6s vs 0.7s): the old +optimizer's cost is in the *search*, which barely runs on one month of two turbines. + +**Hill of Towie, 21 turbines, 2017–2018** (2,207,520 rows, both passes): + +| | old | new | +|---|---|---| +| runtime | 389.9 s | **39.3 s** (9.9x faster) | +| mean max northing error | 2.336° | **2.057°** | +| worst max northing error | 5.121° | **3.665°** | + +Quality is v0's own metric (`check_wtg_northing`: max 20-day rolling circular-median error +against the wind-farm yaw direction), so neither implementation is scored on its own objective. +The new estimator is better or equal on 14 of 21 turbines and never worse by more than 0.25°; +the two largest gains are **T06 5.12 → 2.07** and **T15 4.01 → 2.72**, both turbines where it +finds a real changepoint the hill-climb missed. T06 being the biggest win matters directly — +it is the R1 fixture turbine. + +Agreement is tight: median |new − old| ≤ 0.45° on every turbine. The turbines with a larger p95 +(T06 5.26°, T09 3.46°, T15 3.20°) are precisely those where the new estimator found an extra +changepoint, which is also where its error metric improves most. + +So "same or better performance" holds on both axes, and "MUCH faster" is **9.9x at farm scale** +— a number, and one that grows with record length and turbine count, since the old search cost +scales far worse than the DP's. + +### Test strategy + +The core is pure and array-based, so its tests are fast and synthetic. They land in +`tests/wind_up/test_northing.py` (alongside the existing `tests/wind_up/test_farm.py`); +`tests/test_northing.py` and `tests/test_optimize_northing.py` stay where they are, testing +v0's unchanged helpers and the adapter respectively. + +- known steps at known times, recovered to within tolerance; +- wrap-around at 0/360 in both the raw and the northed signal; +- the all-180° case; +- noise floor: a step just below `min_step_deg` is not reported, one just above is; +- degenerate input (empty, all-NaN, all-unusable, a single segment) returns a valid one-row table; +- effort tiers: `fast` finds one large step; `thorough` finds small ones `fast` misses. + +The fixture runs are drivers, not unit tests. The pytest layer gets a tiny-frame end-to-end +proving the shared step wires through `CampaignRunner`, and that `power_model` raises a +named error when the northed column is absent. + +**At least one test runs on real data** — Hill of Towie, already available via git-lfs. +Synthetic tests pin the algorithm's contract but cannot expose what real SCADA does to it, +so a purely synthetic suite leaves a gap exactly where this issue lives. + +## Designed for, not implemented + +**The incremental re-run.** An analyst supplies a prior north table and asks wind-up to +check it again — either from scratch or with the prior already applied — and wants back +either "confirmed" or a list of amendments. This is normal usage on a live campaign: re-run +monthly as data arrives, and the new data may contain a north jump nobody knew about. + +**Decision 5 (tables are always absolute) is the whole of what R1 does for this**, and it is +enough: a supplied table and a freshly estimated one are directly comparable, so "confirmed +or amended" is a subtraction over two absolute tables. Nothing else needs to exist yet. + +Left for later: a mode that estimates *and* compares rather than choosing between them; a +`prior_mode` selecting whether supplied changepoints are pinned or re-optimised; and the +report that states "confirmed" or lists amendments. + +Deliberately **not** built now: `estimate_north_table` takes no `prior` argument. Seeding the +search from a supplied table has no caller under the supplied-or-discovered rule above, and a +parameter that sits unused across issues drifts — the same argument the C2 design made for +keeping unread fields off `CampaignContext`. It is a small addition when a caller exists. + +The success condition for all of this is that it stays rarely used — a norther fast and +accurate enough that supplying a table stops being worth the analyst's time. + +**Pairwise consensus.** HOGER's ">50% of pairwise comparisons" vote as an alternative to +the farm-median reference, for farms with few turbines. + +**Masts and LiDARs.** The core already accepts any direction field; what is missing is a +wind-speed-based `usable` helper and the plumbing to declare non-turbine devices. + +## Risks + +- **Making the fault bite `power_model` at all.** The direction feature is new, so how much + the model leans on it is unknown until measured. If it leans lightly, the injected step may + need to be large to bite, which strains realism. Mitigated by the natural-case probe, which + sizes a real occurrence first. If the feature turns out to carry little weight, ERA5 wind + direction can be withheld from `power_model` as a deliberate intervention, forcing it onto + the turbine direction signal and putting the northing step under real pressure. +- **Small-N farm reference.** Four turbines with one jumping makes the farm-median direction + noisier than at HoT's 21. The reanalysis pass anchors it, and pairwise consensus is the + recorded fallback. +- **`min_step_deg` → penalty conversion.** The mapping from a degrees threshold to a + resultant-length penalty needs calibrating against the noise floor rather than derived + once on paper; the noise-floor test is what pins it. +- **v0 parity on real data.** "Same or better" is judged on the HoT farm-scale comparison. + A turbine where the new table differs materially needs explaining, not averaging away. diff --git a/docs/v1/issues_campaigns.md b/docs/v1/issues_campaigns.md index 6a8812ff..5e8d4c8a 100644 --- a/docs/v1/issues_campaigns.md +++ b/docs/v1/issues_campaigns.md @@ -408,18 +408,29 @@ Every R-issue shares a two-phase acceptance, run in **both prepost and toggle**: ## R1 — Northing errors (shared fix) **Goal:** wind-up recovers a known uplift despite a turbine's direction reference -carrying a **step change** in its offset partway through the record. +carrying a **step change** in its north calibration partway through the record. **Scope** -- **Fault (generator):** inject a known **step** in reported wind direction for some +- **Fault (generator):** inject a known **step** in reported yaw angle or wind direction for some turbine(s) at a date (a recalibration / sensor swap). **Steps only — no drifts.** - **Fix:** a **shared northing-correction feature-engineering step** in the runner / preprocessing, upstream of every method, so every method inherits it. -- Develop on the tiny fixture; land before C3 so the prepost campaign inherits it. +- Develop on a tiny fixture; land before C3 so the prepost campaign inherits it. -**Done when:** the step bites `power_model` on the clean fixture, then the shared +Notes on potential test turbines: +T06 is thought to be the best, however its likely best reference T05 has natural step changes in its yaw direction already in 2017 and 2018. That is not necessarily a problem but it means we are not working from a clean slate. But as a starting point could try running v0 on T06 for pre-post 2017-2018 with and without northing correction to see sensitivity of this naturally occuring example of the failure mode. +T11's surrounding turbines all have stable northing in 2017 and 2018 according to HOT open optimized_northing_corrections.yaml + +other notes: +- `power_model` might need a little development if it does not use reference turbine yaw/wind direction at all yet (I think it does not, precisely because it could not see north calibrated versions up till now) +- reference turbine yaw direction is generally preferred over wind direction for the same reasons power is preferred over wind speed. +- `naive_ratio` and `toggle_specialist` do not use wind direction so are out of scope in this issue + +**Done when:** +the step bites `v0` and `power_model` on the clean fixture, then the shared northing step restores invariance; C3/C5 drop their bespoke northing wiring in favour of this step. +the developed solution can be a drop-in replacement for the existing src/wind_up_v0/optimize_northing.py. Same or better performance is proven and useful test cases are ported. It should run MUCH faster (the old solution is a hand-rolled optimizer) and not require exotic dependencies (drop `ruptures`) --- diff --git a/pyproject.toml b/pyproject.toml index 102ad514..55eb3ba1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ dependencies = [ 'pydantic >= 2.0.0', 'python-dotenv', 'pyyaml', - 'ruptures', 'scipy', 'seaborn', 'tabulate', @@ -157,7 +156,6 @@ module = [ "geographiclib.geodesic", "pandas", "pandas.testing", - "ruptures.*", "scipy.interpolate", "scipy.ndimage", "scipy.stats", diff --git a/src/wind_up/circular_math.py b/src/wind_up/circular_math.py new file mode 100644 index 00000000..c96dc355 --- /dev/null +++ b/src/wind_up/circular_math.py @@ -0,0 +1,130 @@ +"""Circular math functions missing from numpy/scipy.""" + +from __future__ import annotations + +import numpy as np +import numpy.typing as npt +import pandas as pd +from scipy.stats import circmean + + +def circ_diff(angle1: float | npt.NDArray | list, angle2: float | npt.NDArray | list) -> float | npt.NDArray: + """Calculate the circular difference between two angles. + + :param angle1: First angle in degrees. + :param angle2: Second angle in degrees. + :return: Circular difference between the two angles in degrees + """ + # Convert list to numpy array + if isinstance(angle1, list): + angle1 = np.array(angle1) + if isinstance(angle2, list): + angle2 = np.array(angle2) + + return np.mod(angle1 - angle2 + 180, 360) - 180 + + +def circ_median(angles: npt.NDArray, axis: int | None = None, *, range_360: bool = True) -> float | npt.NDArray: + """Calculate the circular median of angles. + + Uses an efficient approximation: centers data around the circular mean, + computes ordinary median, then rotates back. + + :param angles: Array of angles in degrees. Can be a numpy array, list, or pandas Series. + Input can be in any range; it will be normalized internally. + :param axis: Axis along which to compute the median. If None, compute over flattened array. + :param range_360: If True, return result in [0, 360). If False, return result in [-180, 180). + :return: Circular median in degrees + """ + # Convert to numpy array (handles lists, Series, etc.) + angles = np.asarray(angles) + + # Handle axis parameter + if axis is not None: + return np.apply_along_axis(lambda x: circ_median(x, axis=None, range_360=range_360), axis, angles) + + # Flatten if needed + angles = angles.flatten() + + # Remove NaN values + angles = angles[~np.isnan(angles)] + + if len(angles) == 0: + return np.nan + + # Normalize angles to [0, 360) for computation + angles_normalized = np.mod(angles, 360) + + # Calculate circular mean (in radians for scipy, convert back to degrees) + mean_angle = circmean(angles_normalized, high=360, low=0) + + # Center the data around 180 (subtract mean, add 180) + centered_angles = np.mod(angles_normalized - mean_angle + 180, 360) + + # Compute ordinary median on centered data + median_centered = np.median(centered_angles) + + # Rotate back (subtract 180, add mean back) + median_angle = np.mod(median_centered - 180 + mean_angle, 360) + + # Convert to requested range + if range_360: + return median_angle + # Convert to [-180, 180) + return np.mod(median_angle + 180, 360) - 180 + + +def rolling_circ_mean( + series: pd.Series, *, window: int, min_periods: int, center: bool = False, range_360: bool = True +) -> pd.Series: + """Efficient rolling circular mean for angles in degrees. + + :param series: Series of angles in degrees. + :param window: Size of the rolling window. + :param min_periods: Minimum number of observations required to have a value. + :param center: If True, set the labels at the center of the window. + :param range_360: If True, return result in [0, 360). If False, return result in [-180, 180). + :return: Series with rolling circular mean. + """ + rad_values = np.deg2rad(series) + sin_series = pd.Series(np.sin(rad_values), index=series.index) + cos_series = pd.Series(np.cos(rad_values), index=series.index) + + sin_rolling = sin_series.rolling(window=window, min_periods=min_periods, center=center).mean() + cos_rolling = cos_series.rolling(window=window, min_periods=min_periods, center=center).mean() + + result = (np.rad2deg(np.arctan2(sin_rolling, cos_rolling)) + 360) % 360 + + if not range_360: + # Convert to [-180, 180) + result = np.mod(result + 180, 360) - 180 + + return result + + +def rolling_circ_median_approx( + series: pd.Series, *, window: int, min_periods: int, center: bool = False, range_360: bool = True +) -> pd.Series: + """Efficient rolling circular (approximate) median for angles in degrees. + + :param series: Series of angles in degrees. + :param window: Size of the rolling window. + :param min_periods: Minimum number of observations required to have a value. + :param center: If True, set the labels at the center of the window. + :param range_360: If True, return result in [0, 360). If False, return result in [-180, 180). + :return: Series with rolling circular median. + """ + rad_values = np.deg2rad(series) + sin_series = pd.Series(np.sin(rad_values), index=series.index) + cos_series = pd.Series(np.cos(rad_values), index=series.index) + + sin_rolling = sin_series.rolling(window=window, min_periods=min_periods, center=center).median() + cos_rolling = cos_series.rolling(window=window, min_periods=min_periods, center=center).median() + + result = (np.rad2deg(np.arctan2(sin_rolling, cos_rolling)) + 360) % 360 + + if not range_360: + # Convert to [-180, 180) + result = np.mod(result + 180, 360) - 180 + + return result diff --git a/src/wind_up/northing.py b/src/wind_up/northing.py new file mode 100644 index 00000000..1b65a332 --- /dev/null +++ b/src/wind_up/northing.py @@ -0,0 +1,706 @@ +"""Estimate and apply north-calibration corrections for a direction signal. + +A turbine's reported yaw direction carries an unknown offset from true north that changes in +**steps** when the sensor is recalibrated or replaced. :func:`estimate_north_table` recovers +those steps by comparing the signal with a reference direction, and returns a table of +``(timestamp, north_offset)`` that :func:`apply_north_table` steps onto the raw signal. + +Offsets are always **absolute** -- relative to the raw field, never to an already-corrected +one -- so a supplied table and an estimated one are directly comparable and repeated runs +compose. + +:func:`north_farm` runs the two-pass farm workflow: north every device to reanalysis, build a +farm consensus direction from the results, then north every device to that. The second pass is +the more precise one; the first is what anchors the farm in absolute terms, without which a +farm that is uniformly wrong looks perfectly self-consistent. + +The estimator works on any direction field. Only :func:`yaw_usable` is turbine-specific -- a +mast or LiDAR needs a wind-speed-based mask instead, which is not wired up yet. +""" + +from __future__ import annotations + +import itertools +import logging +import math +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING + +import numpy as np +import pandas as pd + +from wind_up.circular_math import circ_diff, circ_median + +if TYPE_CHECKING: + from collections.abc import Mapping + + import numpy.typing as npt + +logger = logging.getLogger(__name__) + +TIMESTAMP_COL = "timestamp" +NORTH_OFFSET_COL = "north_offset" + +# A turbine's yaw reading is only meaningful when it is generating; below this fraction of +# rated power it often points away from the wind. +YAW_OK_POWER_FRACTION = 0.05 +# Above this many aggregation bins the cost matrix gets large (it is O(bins^2)); warn rather +# than fail, since the result is still correct. +_BIN_COUNT_WARN = 3000 +# Search-shape defaults, as constants so the dataclass defaults are not function calls. +_DEFAULT_GRID = pd.Timedelta(days=1) +_DEFAULT_MIN_SEGMENT = pd.Timedelta(days=7) +# A segment needs a row either side of a candidate split for the split to mean anything. +_MIN_ROWS_TO_SPLIT = 2 +# Direction sectors the residual is normalised over before the changepoint search. +_DEFAULT_VEER_SECTOR_DEG = 30.0 +# A sector with fewer usable rows than this has no trustworthy level of its own. +_MIN_ROWS_PER_SECTOR = 50 +# A step larger than this is a recalibration whatever else the record does, so it is never ironed +# out as wander -- real ones do sometimes reverse later. +_MAX_TRANSIENT_STEP_DEG = 10.0 + + +@dataclass(frozen=True) +class NorthingSettings: + """How the changepoint search is bounded, in physical units. + + There is one setting, not a menu: a low-effort tier was measured and dropped, because the + search is a small part of the runtime (a whole farm-year differs by ~2 seconds) and a smaller + changepoint budget cost real detections. Construct one of these only to tune deliberately. + + :param changepoints_per_year: budget of changepoints per year of record, so a longer + record is allowed more; the cap is ``max(min_changepoints, ceil(rate * years))`` + :param min_changepoints: floor on that budget, so a short record can still hold several + corrections + :param min_step_deg: the smallest step reported. A changepoint whose estimated step is + below this is dropped and its segments merged. + :param refine: pin each changepoint to native resolution after the search, instead of + leaving it on a ``grid`` boundary + :param grid: aggregation bin for the changepoint search + :param min_segment: shortest allowed gap between changepoints + :param veer_sector_deg: width of the direction sectors the residual is normalised over + before the changepoint search, cancelling site veer (see :func:`veer_normalised`). + ``None`` searches the raw residual. + :param max_transient_step_deg: the largest step that may be ironed out as wander. Above it a + step is treated as a recalibration however the record behaves afterwards, since real ones + are sometimes reversed later. + """ + + changepoints_per_year: float = 12.0 + min_step_deg: float = 3.0 + refine: bool = True + min_changepoints: int = 3 + grid: pd.Timedelta = _DEFAULT_GRID + min_segment: pd.Timedelta = _DEFAULT_MIN_SEGMENT + veer_sector_deg: float | None = _DEFAULT_VEER_SECTOR_DEG + max_transient_step_deg: float = _MAX_TRANSIENT_STEP_DEG + + +# Reanalysis is a modelled, drift-prone direction: a shift in it looks exactly like a shift in +# every turbine at once, so only large steps may be attributed to a turbine against it. A farm +# consensus shares that common-mode error, so against one a residual step really is the +# turbine's. See :func:`against_reanalysis`. +REANALYSIS_MIN_STEP_DEG = 10.0 + +DEFAULT_NORTHING = NorthingSettings() + + +def against_reanalysis(settings: NorthingSettings) -> NorthingSettings: + """Return ``settings`` made safe for northing against reanalysis rather than a farm consensus. + + Raises ``min_step_deg`` to at least :data:`REANALYSIS_MIN_STEP_DEG`, so drift in the + reanalysis reference is not attributed to the turbines as a small step change. Everything + else is unchanged. + """ + if settings.min_step_deg >= REANALYSIS_MIN_STEP_DEG: + return settings + return replace(settings, min_step_deg=REANALYSIS_MIN_STEP_DEG) + + +def yaw_usable( + *, + power: npt.NDArray[np.float64], + downtime_s: npt.NDArray[np.float64], + reference_deg: npt.NDArray[np.float64], + rated_power: float, + timebase_s: float, +) -> npt.NDArray[np.bool_]: + """Rows where a turbine's yaw reading may be used for northing. + + The turbine must be generating (above :data:`YAW_OK_POWER_FRACTION` of rated), largely + free of downtime within the record, and have a reference direction to compare against. + """ + return np.asarray( + np.isfinite(reference_deg) + & np.isfinite(power) + & (np.nan_to_num(power, nan=-1.0) > rated_power * YAW_OK_POWER_FRACTION) + & (np.nan_to_num(downtime_s, nan=0.0) < timebase_s / 4), + dtype=bool, + ) + + +def _table(timestamps: list[pd.Timestamp], offsets: list[float]) -> pd.DataFrame: + """Build a north table from parallel timestamp and offset lists.""" + return pd.DataFrame({TIMESTAMP_COL: pd.DatetimeIndex(timestamps), NORTH_OFFSET_COL: offsets}) + + +def _residual( + direction_deg: npt.NDArray[np.float64], + reference_deg: npt.NDArray[np.float64], + usable: npt.NDArray[np.bool_], +) -> npt.NDArray[np.float64]: + """Signed circular difference direction - reference (deg), NaN where unusable.""" + residual = np.asarray(circ_diff(direction_deg, reference_deg), dtype=float) + keep = usable & np.isfinite(direction_deg) & np.isfinite(reference_deg) + return np.where(keep, residual, np.nan) + + +def _de_stepped( + residual: npt.NDArray[np.float64], *, index: pd.DatetimeIndex, edges: list[pd.Timestamp] +) -> npt.NDArray[np.float64]: + """Return ``residual`` with each segment's own level removed, leaving the within-segment shape. + + Measuring the veer signature needs the step structure out of the way first: a sector's level + would otherwise average across the steps, and uneven direction sampling between segments would + distort the very steps being looked for. + """ + out = residual.copy() + for begin, finish in itertools.pairwise(edges): + rows = np.asarray((index >= begin) & (index < finish)) + values = residual[rows] + finite = values[np.isfinite(values)] + if len(finite) == 0: + continue + out[rows] = np.asarray(circ_diff(values, circ_median(finite, range_360=False)), dtype=float) + return out + + +def veer_normalised( + residual: npt.NDArray[np.float64], + *, + reference_deg: npt.NDArray[np.float64], + sector_deg: float, + de_stepped: npt.NDArray[np.float64] | None = None, + min_rows_per_sector: int = _MIN_ROWS_PER_SECTOR, +) -> npt.NDArray[np.float64]: + """Remove each direction sector's own long-run level from the residual. + + Across a site the wind direction differs from turbine to turbine -- veer, varying with the + bulk direction, stability and wind speed. A turbine's residual therefore has a level that + depends on *which* directions the wind blew from, so a shift in the direction mix moves the + level without anything at the turbine changing, and a changepoint search reads that as a step. + + Subtracting each sector's whole-record median removes it: a genuine north offset shifts every + sector alike and so survives, while a change in the mix cannot move the level at all. Sectors + with too little data fall back to the overall level. + + Use this for **detection only** -- segment offsets are estimated from the raw residual, so the + correction stays absolute. + + :param de_stepped: the residual with a first-pass estimate of the step structure removed. The + sector levels are measured on it rather than on ``residual``, so a large step cannot leak + into the veer signature. Defaults to ``residual`` itself. + """ + finite = np.isfinite(residual) & np.isfinite(reference_deg) + if not finite.any(): + return residual + measured_on = residual if de_stepped is None else de_stepped + n_sectors = max(1, int(np.ceil(360.0 / sector_deg))) + sector = np.zeros(len(residual), dtype=int) + sector[finite] = (np.mod(reference_deg[finite], 360.0) // sector_deg).astype(int) % n_sectors + + overall = float(circ_median(measured_on[finite], range_360=False)) + level = np.full(n_sectors, overall) + for s in range(n_sectors): + rows = finite & (sector == s) & np.isfinite(measured_on) + if int(rows.sum()) >= min_rows_per_sector: + level[s] = float(circ_median(measured_on[rows], range_360=False)) + + out = residual.copy() + out[finite] = np.asarray(circ_diff(residual[finite], level[sector[finite]]), dtype=float) + return out + + +def _bin_levels( + residual: npt.NDArray[np.float64], *, bins: npt.NDArray[np.int64], n_bins: int +) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: + """Per-bin circular median of the residual (deg) and the count backing it. + + The median is taken about each bin's circular mean, which is what makes it well defined + across the 0/360 wrap. Empty bins get level 0 and weight 0, so they cost nothing. + """ + finite = np.isfinite(residual) + bin_of = bins[finite] + values = residual[finite] + counts = np.bincount(bin_of, minlength=n_bins).astype(float) + if len(values) == 0: + return np.zeros(n_bins), counts + + rad = np.deg2rad(values) + sin_sum = np.bincount(bin_of, weights=np.sin(rad), minlength=n_bins) + cos_sum = np.bincount(bin_of, weights=np.cos(rad), minlength=n_bins) + mean_deg = np.degrees(np.arctan2(sin_sum, cos_sum)) + + centred = (values - mean_deg[bin_of] + 180.0) % 360.0 - 180.0 + median_centred = pd.Series(centred).groupby(bin_of).median().reindex(range(n_bins)).to_numpy(dtype=float) + level = (np.nan_to_num(median_centred) + mean_deg + 180.0) % 360.0 - 180.0 + return np.where(counts > 0, level, 0.0), counts + + +def _segment_costs(level_deg: npt.NDArray[np.float64], weight: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: + """Cost matrix ``C[i, j]`` of treating bins ``[i, j)`` as one constant-offset segment. + + The cost is ``W - R``: total weight minus the length of the weighted resultant vector, + which is the loss the circular mean minimises and is zero for a perfectly coherent + segment. Prefix sums make every entry O(1), so the whole matrix is one vectorised pass. + """ + rad = np.deg2rad(level_deg) + cum_w = np.concatenate([[0.0], np.cumsum(weight)]) + cum_cos = np.concatenate([[0.0], np.cumsum(weight * np.cos(rad))]) + cum_sin = np.concatenate([[0.0], np.cumsum(weight * np.sin(rad))]) + total_w = cum_w[None, :] - cum_w[:, None] + resultant = np.hypot(cum_cos[None, :] - cum_cos[:, None], cum_sin[None, :] - cum_sin[:, None]) + return np.asarray(total_w - resultant) + + +def _best_breakpoints(cost: npt.NDArray[np.float64], *, max_k: int, min_span: int, penalty: float) -> list[int]: + """Bin indices of the optimal changepoints, by exact dynamic programming. + + ``best[k][j]`` is the least cost of splitting bins ``[0, j)`` into ``k + 1`` segments; + each ``k`` is solved from ``k - 1`` in one vectorised minimisation. The reported ``k`` is + the one minimising ``best[k][n] + penalty * k``. + """ + n = cost.shape[0] - 1 + span = np.arange(n + 1)[None, :] - np.arange(n + 1)[:, None] + feasible = np.where(span >= min_span, cost, np.inf) + + best = np.full((max_k + 1, n + 1), np.inf) + came_from = np.zeros((max_k + 1, n + 1), dtype=int) + best[0] = feasible[0] + for k in range(1, max_k + 1): + total = best[k - 1][:, None] + feasible + came_from[k] = np.argmin(total, axis=0) + best[k] = total[came_from[k], np.arange(n + 1)] + + penalised = best[:, n] + penalty * np.arange(max_k + 1) + if not np.isfinite(penalised).any(): + return [] + k = int(np.nanargmin(np.where(np.isfinite(penalised), penalised, np.nan))) + + breakpoints: list[int] = [] + j = n + while k > 0: + i = int(came_from[k][j]) + breakpoints.append(i) + j, k = i, k - 1 + return sorted(breakpoints) + + +def _native_prefix_sums( + residual: npt.NDArray[np.float64], +) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.float64], npt.NDArray[np.float64]]: + """Positions of finite residual rows and prefix sums of their cos/sin, for local scoring.""" + finite = np.flatnonzero(np.isfinite(residual)) + rad = np.deg2rad(residual[finite]) + cum_cos = np.concatenate([[0.0], np.cumsum(np.cos(rad))]) + cum_sin = np.concatenate([[0.0], np.cumsum(np.sin(rad))]) + return finite, cum_cos, cum_sin + + +def _local_cost( + lo: npt.NDArray[np.int64] | int, + hi: npt.NDArray[np.int64] | int, + *, + cum_cos: npt.NDArray[np.float64], + cum_sin: npt.NDArray[np.float64], +) -> npt.NDArray[np.float64]: + """``W - R`` over finite-row positions ``[lo, hi)``; the native-resolution segment cost.""" + weight = np.asarray(hi, dtype=float) - np.asarray(lo, dtype=float) + resultant = np.hypot(cum_cos[hi] - cum_cos[lo], cum_sin[hi] - cum_sin[lo]) + return np.asarray(weight - resultant) + + +def _refine( + changepoints: list[pd.Timestamp], + *, + start: pd.Timestamp, + end: pd.Timestamp, + finite_times: pd.DatetimeIndex, + cum_cos: npt.NDArray[np.float64], + cum_sin: npt.NDArray[np.float64], + settings: NorthingSettings, +) -> list[pd.Timestamp]: + """Move each changepoint to the native timestamp that best splits its two neighbours. + + Searches within one grid bin either side, while keeping ``min_segment`` clear of the + neighbouring changepoints. + """ + refined = list(changepoints) + # integer nanoseconds throughout, so tz-aware and tz-naive inputs compare alike + times = finite_times.asi8 + for position, changepoint in enumerate(refined): + previous = refined[position - 1] if position > 0 else start + following = refined[position + 1] if position + 1 < len(refined) else end + earliest = max(changepoint - settings.grid, previous + settings.min_segment) + latest = min(changepoint + settings.grid, following - settings.min_segment) + if earliest >= latest: + continue + span_lo = int(np.searchsorted(times, previous.value)) + span_hi = int(np.searchsorted(times, following.value)) + first = int(np.searchsorted(times, earliest.value)) + last = int(np.searchsorted(times, latest.value)) + if last <= first or span_hi - span_lo < _MIN_ROWS_TO_SPLIT: + continue + candidates = np.arange(max(first, span_lo + 1), min(last, span_hi - 1) + 1) + if len(candidates) == 0: + continue + totals = _local_cost(span_lo, candidates, cum_cos=cum_cos, cum_sin=cum_sin) + _local_cost( + candidates, span_hi, cum_cos=cum_cos, cum_sin=cum_sin + ) + refined[position] = finite_times[int(candidates[int(np.argmin(totals))])] + return refined + + +def _segment_offsets( + changepoints: list[pd.Timestamp], + *, + start: pd.Timestamp, + residual: npt.NDArray[np.float64], + index: pd.DatetimeIndex, +) -> list[float]: + """Return each segment's correcting offset: minus the circular median of its residual.""" + edges = [start, *changepoints, index.max() + pd.Timedelta(nanoseconds=1)] + offsets = [] + for begin, finish in itertools.pairwise(edges): + rows = residual[(index >= begin) & (index < finish)] + rows = rows[np.isfinite(rows)] + median = circ_median(rows, range_360=False) if len(rows) else 0.0 + offsets.append(0.0 if not np.isfinite(median) else -float(median)) + return offsets + + +def _weighted_level(offsets: npt.NDArray[np.float64], weights: npt.NDArray[np.float64]) -> float: + """Duration-weighted circular mean of a run of segment offsets (deg).""" + rad = np.deg2rad(offsets) + return float(np.degrees(np.arctan2(np.sum(weights * np.sin(rad)), np.sum(weights * np.cos(rad))))) + + +def _persistence(offsets: list[float], *, durations: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]: + """How much each changepoint moves the long-run level, in degrees. + + A recalibration moves the level and leaves it moved. An excursion -- the level wandering away + and back -- moves it only in between, so the record either side of any one of its changepoints + sits at the same place. + """ + values = np.asarray(offsets, dtype=float) + return np.array( + [ + abs( + float( + circ_diff( + _weighted_level(values[k + 1 :], durations[k + 1 :]), + _weighted_level(values[: k + 1], durations[: k + 1]), + ) + ) + ) + for k in range(len(values) - 1) + ] + ) + + +def _prune_transient_steps( + changepoints: list[pd.Timestamp], + offsets: list[float], + *, + start: pd.Timestamp, + end: pd.Timestamp, + residual: npt.NDArray[np.float64], + index: pd.DatetimeIndex, + min_step_deg: float, + max_transient_step_deg: float, +) -> tuple[list[pd.Timestamp], list[float]]: + """Iron out small excursions -- site veer wandering away and back, rather than a recalibration. + + Repeatedly removes the least persistent changepoint while any **small** one fails to move the + long-run level by ``min_step_deg``, re-estimating the offsets after each merge. Steps larger + than ``max_transient_step_deg`` are never removed: a real recalibration is sometimes reversed + later, and its size is the evidence that it happened. + """ + while len(changepoints) > 0: + edges = [start, *changepoints, end] + durations = np.array([max((b - a).total_seconds(), 1.0) for a, b in itertools.pairwise(edges)], dtype=float) + persistence = _persistence(offsets, durations=durations) + steps = np.abs(circ_diff(np.array(offsets[1:]), np.array(offsets[:-1]))) + candidates = np.flatnonzero((steps < max_transient_step_deg) & (persistence < min_step_deg)) + if len(candidates) == 0: + break + weakest = int(candidates[np.argmin(persistence[candidates])]) + changepoints = [c for i, c in enumerate(changepoints) if i != weakest] + offsets = _segment_offsets(changepoints, start=start, residual=residual, index=index) + return changepoints, offsets + + +def _prune_small_steps( + changepoints: list[pd.Timestamp], + offsets: list[float], + *, + start: pd.Timestamp, + residual: npt.NDArray[np.float64], + index: pd.DatetimeIndex, + min_step_deg: float, +) -> tuple[list[pd.Timestamp], list[float]]: + """Drop changepoints whose estimated step is below ``min_step_deg``, smallest first. + + This is what makes ``min_step_deg`` mean what it says: a step smaller than it is never + reported, however much data supports it. Offsets are re-estimated after each merge, since + merging two segments changes the level of the result. + """ + while changepoints: + steps = np.abs(circ_diff(np.array(offsets[1:]), np.array(offsets[:-1]))) + smallest = int(np.argmin(steps)) + if steps[smallest] >= min_step_deg: + break + changepoints = [c for i, c in enumerate(changepoints) if i != smallest] + offsets = _segment_offsets(changepoints, start=start, residual=residual, index=index) + return changepoints, offsets + + +def estimate_north_table( + index: pd.DatetimeIndex, + direction_deg: npt.NDArray[np.float64], + *, + reference_deg: npt.NDArray[np.float64], + usable: npt.NDArray[np.bool_], + settings: NorthingSettings = DEFAULT_NORTHING, +) -> pd.DataFrame: + """Estimate a direction signal's north offsets over time. + + Compares ``direction_deg`` with ``reference_deg`` over the rows ``usable`` allows, finds + the step changes in their circular difference, and returns the offset that corrects each + resulting period. Offsets are absolute: adding one to the **raw** signal norths it. + + :param index: timestamps of every array; need not be sorted + :param direction_deg: the signal to north, in degrees + :param reference_deg: the direction to north it against (reanalysis, or a farm consensus) + :param usable: rows whose comparison is meaningful -- see :func:`yaw_usable`. Also the + place to exclude periods when the direction is deliberately offset, such as a turbine + steering its wake. + :param settings: how the search is bounded; the default suits a farm record and there is no + tier to choose between + :return: columns ``timestamp`` and ``north_offset``, one row per period, the first row at + the start of ``index``. Always at least one row; all-zero when nothing is usable. + """ + index = pd.DatetimeIndex(index) + if len(index) == 0: + msg = "cannot estimate a north table from an empty index" + raise ValueError(msg) + direction = np.asarray(direction_deg, dtype=float) + reference = np.asarray(reference_deg, dtype=float) + ok = np.asarray(usable, dtype=bool) + if not len(direction) == len(reference) == len(ok) == len(index): + msg = ( + f"index, direction_deg, reference_deg and usable must be the same length; got " + f"{len(index)}, {len(direction)}, {len(reference)}, {len(ok)}" + ) + raise ValueError(msg) + + if not index.is_monotonic_increasing: + order = np.argsort(index.to_numpy()) + index, direction, reference, ok = index[order], direction[order], reference[order], ok[order] + + residual = _residual(direction, reference, ok) + start = index.min() + if not np.isfinite(residual).any(): + logger.warning("no usable rows to north against; returning a zero offset") + return _table([start], [0.0]) + + bins = ((index - start) // settings.grid).to_numpy().astype(np.int64) + n_bins = int(bins.max()) + 1 + if n_bins > _BIN_COUNT_WARN: + logger.warning("northing over %d %s bins; consider a coarser grid", n_bins, settings.grid) + years = (index.max() - start) / pd.Timedelta(days=365.25) + max_k = max(settings.min_changepoints, math.ceil(settings.changepoints_per_year * max(years, 0.0))) + min_span = max(1, math.ceil(settings.min_segment / settings.grid)) + end = index.max() + pd.Timedelta(nanoseconds=1) + + def detect(searched: npt.NDArray[np.float64]) -> list[pd.Timestamp]: + """Return one residual's changepoint timestamps: aggregate, solve, then refine.""" + level, weight = _bin_levels(searched, bins=bins, n_bins=n_bins) + if max_k <= 0 or n_bins <= min_span: + return [] + occupied = int((weight > 0).sum()) + typical = float(weight.sum()) / max(occupied, 1) + # A changepoint must pay for itself: the cost drop a ``min_step_deg`` step sustained + # over ``min_segment`` of typical-density data would produce. + penalty = typical * min_span * (1.0 - math.cos(math.radians(settings.min_step_deg) / 2.0)) + breaks = _best_breakpoints(_segment_costs(level, weight), max_k=max_k, min_span=min_span, penalty=penalty) + found = [start + b * settings.grid for b in breaks if b > 0] + if found and settings.refine: + finite, cum_cos, cum_sin = _native_prefix_sums(searched) + found = _refine( + found, + start=start, + end=end, + finite_times=index[finite], + cum_cos=cum_cos, + cum_sin=cum_sin, + settings=settings, + ) + return found + + changepoints = detect(residual) + if settings.veer_sector_deg is not None: + # Search again in the veer-normalised residual, so a shift in the direction mix cannot look + # like a step. The first pass exists only to take the step structure out of the way while + # the veer signature is measured; offsets come from the raw residual either way, so the + # correction stays absolute. + changepoints = detect( + veer_normalised( + residual, + reference_deg=reference, + sector_deg=settings.veer_sector_deg, + de_stepped=_de_stepped(residual, index=index, edges=[start, *changepoints, end]), + ) + ) + + offsets = _segment_offsets(changepoints, start=start, residual=residual, index=index) + changepoints, offsets = _prune_transient_steps( + changepoints, + offsets, + start=start, + end=end, + residual=residual, + index=index, + min_step_deg=settings.min_step_deg, + max_transient_step_deg=settings.max_transient_step_deg, + ) + changepoints, offsets = _prune_small_steps( + changepoints, + offsets, + start=start, + residual=residual, + index=index, + min_step_deg=settings.min_step_deg, + ) + return _table([start, *changepoints], offsets) + + +def apply_north_table( + index: pd.DatetimeIndex, + direction_deg: npt.NDArray[np.float64], + *, + north_table: pd.DataFrame, +) -> npt.NDArray[np.float64]: + """North a direction signal: ``(direction + offset) % 360``, offsets step-applied. + + Each row of ``north_table`` holds from its timestamp until the next; rows before the first + timestamp take the first offset. Takes a single array so **one table can north several + fields of the same device** -- derive the correction from yaw position, then apply it to + yaw position and to a measured wind-direction channel. NaNs are preserved. + """ + index = pd.DatetimeIndex(index) + direction = np.asarray(direction_deg, dtype=float) + table = north_table.sort_values(TIMESTAMP_COL) + edges = pd.DatetimeIndex(table[TIMESTAMP_COL]).to_numpy() + offsets = table[NORTH_OFFSET_COL].to_numpy(dtype=float) + which = np.clip(np.searchsorted(edges, index.to_numpy(), side="right") - 1, 0, len(offsets) - 1) + return np.where(np.isfinite(direction), (direction + offsets[which]) % 360.0, np.nan) + + +def _farm_direction( + northed: Mapping[str, npt.NDArray[np.float64]], + *, + usable: Mapping[str, npt.NDArray[np.bool_]], + min_devices: int, +) -> npt.NDArray[np.float64]: + """Per-timestamp circular median of the devices' northed directions, NaN where too few.""" + stack = np.vstack( + [np.where(usable[name] & np.isfinite(values), values, np.nan) for name, values in northed.items()] + ) + present = np.isfinite(stack).sum(axis=0) + farm = np.full(stack.shape[1], np.nan) + enough = present >= min_devices + if not enough.any(): + return farm + + columns = stack[:, enough] + rad = np.deg2rad(columns) + # nan-aware circular mean, then the median of the values centred on it + finite = np.isfinite(columns) + counts = finite.sum(axis=0) + mean = np.degrees( + np.arctan2( + np.nansum(np.sin(rad), axis=0) / counts, + np.nansum(np.cos(rad), axis=0) / counts, + ) + ) + centred = (columns - mean + 180.0) % 360.0 - 180.0 + # every retained column has at least ``min_devices`` finite entries, so no all-NaN slice + farm[enough] = (np.nanmedian(centred, axis=0) + mean) % 360.0 + return farm + + +def north_farm( + index: pd.DatetimeIndex, + *, + direction_deg: Mapping[str, npt.NDArray[np.float64]], + usable: Mapping[str, npt.NDArray[np.bool_]], + reanalysis_deg: npt.NDArray[np.float64], + settings: NorthingSettings = DEFAULT_NORTHING, + min_devices_for_farm_reference: int = 3, +) -> dict[str, pd.DataFrame]: + """North a whole farm in two passes, returning one absolute table per device. + + Pass 1 norths each device to ``reanalysis_deg``; the northed directions give a farm + consensus direction, and pass 2 norths each device's **raw** signal to that. Pass 2 is the + more precise of the two, but pass 1 is what fixes the farm in absolute terms: a farm whose + devices are all wrong by the same amount agrees with itself perfectly, so a farm-relative + pass alone cannot see it. + + Every device's arrays are positional on the shared ``index``, which is what lets the farm + consensus be taken across devices at each timestamp. + + :param direction_deg: device name to its raw direction signal + :param usable: device name to the rows usable for northing it + :param reanalysis_deg: the absolute direction reference, on ``index`` + :param min_devices_for_farm_reference: devices that must report at a timestamp for the + consensus to be defined there; also the minimum farm size + """ + devices = sorted(direction_deg) + if len(devices) < min_devices_for_farm_reference: + msg = ( + f"north_farm needs at least min_devices_for_farm_reference={min_devices_for_farm_reference} " + f"devices to form a farm reference, got {len(devices)}: {devices}" + ) + raise ValueError(msg) + missing = sorted(set(devices) - set(usable)) + if missing: + msg = f"usable is missing masks for device(s) {missing}" + raise ValueError(msg) + + # Pass 1's reference is reanalysis, so it may only attribute large steps; pass 2's farm + # consensus is clean enough for the caller's chosen threshold. + anchoring = against_reanalysis(settings) + first_pass = { + name: estimate_north_table( + index, + direction_deg[name], + reference_deg=reanalysis_deg, + usable=usable[name], + settings=anchoring, + ) + for name in devices + } + northed = {name: apply_north_table(index, direction_deg[name], north_table=first_pass[name]) for name in devices} + farm = _farm_direction(northed, usable=usable, min_devices=min_devices_for_farm_reference) + if not np.isfinite(farm).any(): + logger.warning("farm reference is empty; keeping the reanalysis-only north tables") + return first_pass + + return { + name: estimate_north_table( + index, direction_deg[name], reference_deg=farm, usable=usable[name], settings=settings + ) + for name in devices + } diff --git a/src/wind_up_v0/circular_math.py b/src/wind_up_v0/circular_math.py index c96dc355..40d51113 100644 --- a/src/wind_up_v0/circular_math.py +++ b/src/wind_up_v0/circular_math.py @@ -1,130 +1,14 @@ -"""Circular math functions missing from numpy/scipy.""" +"""Circular math functions missing from numpy/scipy. -from __future__ import annotations +Re-exported from :mod:`wind_up.circular_math`, where the implementation now lives so the v1 +northing core can use it without importing from the legacy package. +""" -import numpy as np -import numpy.typing as npt -import pandas as pd -from scipy.stats import circmean +from wind_up.circular_math import ( + circ_diff, + circ_median, + rolling_circ_mean, + rolling_circ_median_approx, +) - -def circ_diff(angle1: float | npt.NDArray | list, angle2: float | npt.NDArray | list) -> float | npt.NDArray: - """Calculate the circular difference between two angles. - - :param angle1: First angle in degrees. - :param angle2: Second angle in degrees. - :return: Circular difference between the two angles in degrees - """ - # Convert list to numpy array - if isinstance(angle1, list): - angle1 = np.array(angle1) - if isinstance(angle2, list): - angle2 = np.array(angle2) - - return np.mod(angle1 - angle2 + 180, 360) - 180 - - -def circ_median(angles: npt.NDArray, axis: int | None = None, *, range_360: bool = True) -> float | npt.NDArray: - """Calculate the circular median of angles. - - Uses an efficient approximation: centers data around the circular mean, - computes ordinary median, then rotates back. - - :param angles: Array of angles in degrees. Can be a numpy array, list, or pandas Series. - Input can be in any range; it will be normalized internally. - :param axis: Axis along which to compute the median. If None, compute over flattened array. - :param range_360: If True, return result in [0, 360). If False, return result in [-180, 180). - :return: Circular median in degrees - """ - # Convert to numpy array (handles lists, Series, etc.) - angles = np.asarray(angles) - - # Handle axis parameter - if axis is not None: - return np.apply_along_axis(lambda x: circ_median(x, axis=None, range_360=range_360), axis, angles) - - # Flatten if needed - angles = angles.flatten() - - # Remove NaN values - angles = angles[~np.isnan(angles)] - - if len(angles) == 0: - return np.nan - - # Normalize angles to [0, 360) for computation - angles_normalized = np.mod(angles, 360) - - # Calculate circular mean (in radians for scipy, convert back to degrees) - mean_angle = circmean(angles_normalized, high=360, low=0) - - # Center the data around 180 (subtract mean, add 180) - centered_angles = np.mod(angles_normalized - mean_angle + 180, 360) - - # Compute ordinary median on centered data - median_centered = np.median(centered_angles) - - # Rotate back (subtract 180, add mean back) - median_angle = np.mod(median_centered - 180 + mean_angle, 360) - - # Convert to requested range - if range_360: - return median_angle - # Convert to [-180, 180) - return np.mod(median_angle + 180, 360) - 180 - - -def rolling_circ_mean( - series: pd.Series, *, window: int, min_periods: int, center: bool = False, range_360: bool = True -) -> pd.Series: - """Efficient rolling circular mean for angles in degrees. - - :param series: Series of angles in degrees. - :param window: Size of the rolling window. - :param min_periods: Minimum number of observations required to have a value. - :param center: If True, set the labels at the center of the window. - :param range_360: If True, return result in [0, 360). If False, return result in [-180, 180). - :return: Series with rolling circular mean. - """ - rad_values = np.deg2rad(series) - sin_series = pd.Series(np.sin(rad_values), index=series.index) - cos_series = pd.Series(np.cos(rad_values), index=series.index) - - sin_rolling = sin_series.rolling(window=window, min_periods=min_periods, center=center).mean() - cos_rolling = cos_series.rolling(window=window, min_periods=min_periods, center=center).mean() - - result = (np.rad2deg(np.arctan2(sin_rolling, cos_rolling)) + 360) % 360 - - if not range_360: - # Convert to [-180, 180) - result = np.mod(result + 180, 360) - 180 - - return result - - -def rolling_circ_median_approx( - series: pd.Series, *, window: int, min_periods: int, center: bool = False, range_360: bool = True -) -> pd.Series: - """Efficient rolling circular (approximate) median for angles in degrees. - - :param series: Series of angles in degrees. - :param window: Size of the rolling window. - :param min_periods: Minimum number of observations required to have a value. - :param center: If True, set the labels at the center of the window. - :param range_360: If True, return result in [0, 360). If False, return result in [-180, 180). - :return: Series with rolling circular median. - """ - rad_values = np.deg2rad(series) - sin_series = pd.Series(np.sin(rad_values), index=series.index) - cos_series = pd.Series(np.cos(rad_values), index=series.index) - - sin_rolling = sin_series.rolling(window=window, min_periods=min_periods, center=center).median() - cos_rolling = cos_series.rolling(window=window, min_periods=min_periods, center=center).median() - - result = (np.rad2deg(np.arctan2(sin_rolling, cos_rolling)) + 360) % 360 - - if not range_360: - # Convert to [-180, 180) - result = np.mod(result + 180, 360) - 180 - - return result +__all__ = ["circ_diff", "circ_median", "rolling_circ_mean", "rolling_circ_median_approx"] diff --git a/src/wind_up_v0/optimize_northing.py b/src/wind_up_v0/optimize_northing.py index fc923769..7cad936e 100644 --- a/src/wind_up_v0/optimize_northing.py +++ b/src/wind_up_v0/optimize_northing.py @@ -1,20 +1,34 @@ -"""Optimize northing corrections.""" +"""Discover northing corrections from the data. + +A thin v0 adapter over :mod:`wind_up.northing`: this module supplies v0's vocabulary (a +MultiIndex wind-farm frame, a :class:`~wind_up_v0.models.WindUpConfig`, the ``raw_`` column +names) and its reporting -- logging, plots and the corrections YAML -- while the estimation +itself is the shared v1 core. + +The two-pass structure is unchanged: north every turbine to reanalysis wind direction, derive +the wind-farm yaw direction from the result, then north every turbine to that. +""" from __future__ import annotations import logging -import math from typing import TYPE_CHECKING import numpy as np -import numpy.typing as npt import pandas as pd -import ruptures as rpt -from ruptures.base import BaseCost -from scipy.stats import circmean -from wind_up_v0.circular_math import circ_diff, circ_median, rolling_circ_median_approx +from wind_up.northing import ( + DEFAULT_NORTHING, + NORTH_OFFSET_COL, + NorthingSettings, + against_reanalysis, + apply_north_table, + estimate_north_table, + yaw_usable, +) +from wind_up_v0.circular_math import circ_diff, rolling_circ_median_approx from wind_up_v0.constants import ( + RAW_DOWNTIME_S_COL, RAW_POWER_COL, RAW_YAWDIR_COL, REANALYSIS_WD_COL, @@ -27,7 +41,7 @@ calc_northed_col_name, check_wtg_northing, ) -from wind_up_v0.northing_utils import YAW_OK_PW_FRACTION, add_ok_yaw_col +from wind_up_v0.northing_utils import add_ok_yaw_col from wind_up_v0.plots.optimize_northing_plots import ( plot_diff_to_north_ref_wd, plot_wf_yawdir_and_reanalysis_timeseries, @@ -41,106 +55,35 @@ logger = logging.getLogger(__name__) -DECAY_FRACTION = 0.4 +# ``add_wf_yawdir`` falls back to reanalysis wherever it cannot form a farm direction (it needs +# three turbines). Below this share of rows differing from reanalysis, the "farm" reference is +# really reanalysis and must be treated as such. +_MIN_INDEPENDENT_FARM_SHARE = 0.5 +# Below this the two directions are the same value, not merely close. +_SAME_DIRECTION_DEG = 1e-6 -class CostCircularL1(BaseCost): - """Custom cost function for detecting changes in circular data which ranges from -180 to 180. +def _farm_reference_is_independent(wf_df: pd.DataFrame) -> bool: + """Whether the wind-farm yaw direction is genuinely farm-derived rather than reanalysis. - Uses L1 norm with circular distance for robustness to outliers. + A farm consensus shares the site's common-mode direction error, which is what makes small + steps attributable to a single turbine. Where it has fallen back to reanalysis it carries no + such information, and the second pass must stay as conservative as the first. """ - - model = "circular_l1" - min_size = 2 - - def __init__(self) -> None: - """Initialize the circular cost function.""" - self.signal: npt.NDArray | None = None - self.min_size = 2 - - def fit(self, signal: npt.NDArray) -> CostCircularL1: - """Set parameters of the instance. - - Args: - signal (array): signal. Shape (n_samples,) or (n_samples, n_features) - - Returns: - self - - """ - self.signal = signal.reshape(-1, 1) if signal.ndim == 1 else signal - - return self - - def error(self, start: int, end: int) -> float: - """Return the approximation cost on the segment [start:end]. - - Args: - start (int): start of the segment - end (int): end of the segment - - Returns: - segment cost - - """ - if end - start < self.min_size: - raise rpt.exceptions.NotEnoughPoints - - if self.signal is None: - msg = "CostCircularL1 not fitted with signal data" - raise RuntimeError(msg) - - sub_signal = self.signal[start:end] - - # cheap and cheerful circ median - ## calc circmean, noting that sub_signal ranges from -180 to +180 - sub_signal_circmean = circmean(sub_signal, low=-180, high=180, nan_policy="omit") - ## rotate the data so 0 represents the circmean, calculate median, then rotate back - sub_signal_zero_centred = (sub_signal - sub_signal_circmean + 180) % 360 - 180 - circmedian = (np.nanmedian(sub_signal_zero_centred) + sub_signal_circmean + 180) % 360 - 180 - # return sum of circ diffs from circ median - abs_circdiffs = abs(circ_diff(sub_signal, circmedian)) - return np.sum(abs_circdiffs) # type: ignore[call-overload] - - -def _northing_score_changepoint_component(changepoint_count: int, *, years_of_data: float) -> float: - return 10 * max(float(changepoint_count - 1), 0) / max(years_of_data, 1) - - -def _northing_score( - wtg_df: pd.DataFrame, - *, - north_ref_wd_col: str, - changepoint_count: int, - rated_power: float, - timebase_s: int, -) -> float: - # this component penalizes long-ish, large north errors - max_component = max(0, wtg_df[f"long_rolling_diff_to_{north_ref_wd_col}"].abs().max() - 4) ** 2 - - # this component penalizes the median north error of filtered data being far from 0 - median_component = max(0, abs(circ_median(wtg_df[f"filt_diff_to_{north_ref_wd_col}"], range_360=False)) - 0.1) ** 2 # type:ignore[operator] - - # this component penalizes raw data having any north errors - max_weight = rated_power * YAW_OK_PW_FRACTION - min_weight = max_weight / 1000 * (min(600, timebase_s) / 600) - raw_wmean_component = ( - wtg_df[f"yaw_diff_to_{north_ref_wd_col}"].clip(lower=-30, upper=30) - * wtg_df[RAW_POWER_COL].clip(lower=min_weight, upper=max_weight) - ).abs().mean() / max_weight - - # this component encourages the correction list to be short - changepoint_component = _northing_score_changepoint_component( - changepoint_count, - years_of_data=(wtg_df.index.max() - wtg_df.index.min()).total_seconds() / (3600 * 24 * 365.25), - ) - - return max_component + median_component + raw_wmean_component + changepoint_component + if WINDFARM_YAWDIR_COL not in wf_df.columns: + return False + farm = wf_df[WINDFARM_YAWDIR_COL].to_numpy(dtype=float) + reanalysis = wf_df[REANALYSIS_WD_COL].to_numpy(dtype=float) + comparable = np.isfinite(farm) & np.isfinite(reanalysis) + if not comparable.any(): + return False + differs = np.abs(circ_diff(farm[comparable], reanalysis[comparable])) > _SAME_DIRECTION_DEG + return bool(differs.mean() >= _MIN_INDEPENDENT_FARM_SHARE) def _add_northing_ok_and_diff_cols(wtg_df: pd.DataFrame, *, north_ref_wd_col: str, northed_col: str) -> pd.DataFrame: + """Add the raw and filtered yaw-minus-reference difference columns the plots read.""" wtg_df = wtg_df.copy() - wtg_df[f"yaw_diff_to_{north_ref_wd_col}"] = circ_diff(wtg_df[northed_col], wtg_df[north_ref_wd_col]) wtg_df[f"filt_diff_to_{north_ref_wd_col}"] = wtg_df[f"yaw_diff_to_{north_ref_wd_col}"] wtg_df.loc[~wtg_df[f"ok_for_{north_ref_wd_col}_northing"], f"filt_diff_to_{north_ref_wd_col}"] = pd.NA @@ -152,556 +95,140 @@ def _add_northed_ok_diff_and_rolling_cols( *, north_ref_wd_col: str, timebase_s: int, - north_offset: float | None = None, - north_offset_df: pd.DataFrame | None = None, + north_table: pd.DataFrame | None = None, ) -> pd.DataFrame: + """Add the northed yaw column plus the difference and rolling-median columns the plots read.""" wtg_df = wtg_df.copy() northed_col = calc_northed_col_name(north_ref_wd_col) - if north_offset is not None: - wtg_df[northed_col] = wtg_df[RAW_YAWDIR_COL] + north_offset - wtg_df[northed_col] = wtg_df[northed_col] % 360 - elif north_offset_df is not None: - for ts, no in zip( - north_offset_df[TIMESTAMP_COL].to_list(), - north_offset_df["north_offset"].to_list(), - strict=True, - ): - wtg_df.loc[wtg_df.index >= ts, northed_col] = wtg_df.loc[wtg_df.index >= ts, RAW_YAWDIR_COL] + no - wtg_df[northed_col] = wtg_df[northed_col] % 360 + index = pd.DatetimeIndex(wtg_df.index) + raw = wtg_df[RAW_YAWDIR_COL].to_numpy(dtype=float) + wtg_df[northed_col] = raw if north_table is None else apply_north_table(index, raw, north_table=north_table) wtg_df = _add_northing_ok_and_diff_cols(wtg_df, north_ref_wd_col=north_ref_wd_col, northed_col=northed_col) - rolling_hours = 6 rows_per_hour = 3600 / timebase_s - wtg_df[f"short_rolling_diff_to_{north_ref_wd_col}"] = rolling_circ_median_approx( - wtg_df[f"filt_diff_to_{north_ref_wd_col}"], - center=True, - window=round(rolling_hours * rows_per_hour), - min_periods=round(rolling_hours * rows_per_hour // 3), - range_360=False, - ) - rolling_hours = 15 * 24 - wtg_df[f"long_rolling_diff_to_{north_ref_wd_col}"] = rolling_circ_median_approx( - wtg_df[f"filt_diff_to_{north_ref_wd_col}"], - center=True, - window=round(rolling_hours * rows_per_hour), - min_periods=round(rolling_hours * rows_per_hour // 3), - range_360=False, - ) - return wtg_df - - -def _calc_good_north_offset(section_df: pd.DataFrame, north_ref_wd_col: str) -> float: - north_errors = section_df[f"filt_diff_to_{north_ref_wd_col}"] - return -float(circ_median(north_errors, range_360=False)) - - -def _calc_north_offset_col( - wtg_north_table: pd.DataFrame, - *, - wtg_df: pd.DataFrame, - north_ref_wd_col: str, - timebase_s: int, -) -> pd.DataFrame: - wtg_df = wtg_df.copy() - wtg_df = _add_northing_ok_and_diff_cols(wtg_df, north_ref_wd_col=north_ref_wd_col, northed_col=RAW_YAWDIR_COL) - - wtg_north_table = wtg_north_table.copy() - wtg_north_table = wtg_north_table.sort_values(by=[TIMESTAMP_COL], ascending=True) - end_dt = wtg_df.index.max() + pd.Timedelta(seconds=timebase_s) - for start_dt in reversed(wtg_north_table[TIMESTAMP_COL].to_list()): - section_df = wtg_df[(wtg_df.index >= start_dt) & (wtg_df.index < end_dt)] - wtg_north_table.loc[ - wtg_north_table[TIMESTAMP_COL] == start_dt, - "north_offset", - ] = _calc_good_north_offset( - section_df, - north_ref_wd_col=north_ref_wd_col, + for label, rolling_hours in (("short", 6), ("long", 15 * 24)): + window = round(rolling_hours * rows_per_hour) + wtg_df[f"{label}_rolling_diff_to_{north_ref_wd_col}"] = rolling_circ_median_approx( + wtg_df[f"filt_diff_to_{north_ref_wd_col}"], + center=True, + window=window, + min_periods=round(window // 3), + range_360=False, ) - end_dt = start_dt - return wtg_north_table + return wtg_df -def _north_table_is_valid( - wtg_north_table: pd.DataFrame, - *, +def _wtg_north_table( wtg_df: pd.DataFrame, - check_north_offset: bool = False, -) -> bool: - is_valid = True - if wtg_north_table[TIMESTAMP_COL].isna().any(): - is_valid = False - if wtg_north_table[TIMESTAMP_COL].duplicated().any(): - is_valid = False - if not wtg_north_table[TIMESTAMP_COL].is_monotonic_increasing: - is_valid = False - if wtg_north_table[TIMESTAMP_COL].min() != wtg_df.index.min(): - is_valid = False - if wtg_north_table[TIMESTAMP_COL].max() > wtg_df.index.max(): - is_valid = False - if check_north_offset: - if wtg_north_table["north_offset"].isna().any(): - is_valid = False - max_abs_north_offset = 180 - if not all(wtg_north_table["north_offset"].abs() <= max_abs_north_offset): - is_valid = False - - return is_valid - - -def _score_wtg_north_table( *, - wtg_north_table: pd.DataFrame, - wtg_df: pd.DataFrame, - improve_north_offset_col: bool, north_ref_wd_col: str, rated_power: float, timebase_s: int, -) -> tuple[pd.DataFrame, float, pd.DataFrame]: - if improve_north_offset_col: - output_north_table = _calc_north_offset_col( - wtg_north_table, - wtg_df=wtg_df, - north_ref_wd_col=north_ref_wd_col, - timebase_s=timebase_s, - ) - else: - output_north_table = wtg_north_table.copy() - output_wtg_df = _add_northed_ok_diff_and_rolling_cols( - wtg_df.copy(), - north_ref_wd_col=north_ref_wd_col, - north_offset_df=output_north_table, - timebase_s=timebase_s, - ) - - score = _northing_score( - output_wtg_df, - north_ref_wd_col=north_ref_wd_col, - changepoint_count=len(output_north_table), - rated_power=rated_power, - timebase_s=timebase_s, - ) - - return output_north_table, score, output_wtg_df - - -def _calc_max_changepoints_to_add(changepoint_count: int, *, score: float, years_of_data: float) -> int: - max_changepoints_to_add = 0 - headroom = score - _northing_score_changepoint_component(changepoint_count + 1, years_of_data=years_of_data) - while headroom > 0: - max_changepoints_to_add += 1 - headroom = score - _northing_score_changepoint_component( - max_changepoints_to_add + 1, years_of_data=years_of_data - ) - return max_changepoints_to_add - - -def _list_possible_moves( - prev_best_north_table: pd.DataFrame, - *, - do_changepoint_moves: bool, - max_changepoints_to_add: int, -) -> list[str]: - moves = [] - moves.extend([f"shift_changepoint_{x}_forward" for x in prev_best_north_table.index[1:]]) - moves.extend([f"shift_changepoint_{x}_back" for x in prev_best_north_table.index[1:]]) - if do_changepoint_moves: - moves.append("add_1_changepoint") - moves.extend([f"add_{x}_changepoints" for x in range(2, max_changepoints_to_add + 1)]) - return moves - - -def _get_changepoint_objects( - *, - prev_best_wtg_df: pd.DataFrame, - north_ref_wd_col: str, -) -> tuple[rpt.base.BaseEstimator, np.ndarray]: - col = f"filt_diff_to_{north_ref_wd_col}" - dropna_df = prev_best_wtg_df.dropna(subset=[col]) - signal = dropna_df[col].to_numpy() - timestamps = dropna_df.index.to_numpy() - custom_cost = CostCircularL1() - algo = rpt.BottomUp(custom_cost=custom_cost).fit(signal) - return algo, timestamps - - -def _make_move( - move: str, - *, - prev_best_north_table: pd.DataFrame, - shift_step_size: int, - do_changepoint_moves: bool, - algo: rpt.base.BaseEstimator, - timestamps: np.ndarray, - timebase_s: int, + settings: NorthingSettings, ) -> pd.DataFrame: - if move.startswith("shift_changepoint_"): - cp_idx = int(move.split("_")[-2]) - sign = 1 if move.endswith("forward") else -1 - this_north_table = prev_best_north_table.copy() - this_north_table.loc[cp_idx, TIMESTAMP_COL] = this_north_table.loc[ - cp_idx, - TIMESTAMP_COL, - ] + pd.Timedelta(seconds=sign * timebase_s * shift_step_size) - elif do_changepoint_moves and move.startswith("add_"): - n_changepoints_to_add = int(move.split("_")[1]) - bkp_idxs = algo.predict(n_bkps=n_changepoints_to_add)[:-1] - if len(bkp_idxs) != n_changepoints_to_add: - msg = f"found {len(bkp_idxs)} bkp_idxs, expected {n_changepoints_to_add}" - raise RuntimeError(msg) - dt_list = list(timestamps[bkp_idxs]) - - this_north_offset_df_dtidx = prev_best_north_table.set_index(TIMESTAMP_COL) - this_index = this_north_offset_df_dtidx.index.append(pd.Index(dt_list)) - this_north_table = this_north_offset_df_dtidx.reindex(this_index).sort_index().reset_index() - this_north_table = this_north_table.rename(columns={"index": TIMESTAMP_COL}) - else: - msg = f"invalid move {move}" - raise RuntimeError(msg) - return this_north_table - - -def _make_move_and_score_wtg_north_table( - move: str, - *, - prev_best_north_table: pd.DataFrame, - wtg_df: pd.DataFrame, - shift_step_size: int, - north_ref_wd_col: str, - rated_power: float, - timebase_s: int, - do_changepoint_moves: bool, - algo: rpt.base.BaseEstimator, - timestamps: np.ndarray, -) -> tuple[pd.DataFrame, float, pd.DataFrame]: - this_north_table = _make_move( - move, - prev_best_north_table=prev_best_north_table, - shift_step_size=shift_step_size, - do_changepoint_moves=do_changepoint_moves, - algo=algo, - timestamps=timestamps, - timebase_s=timebase_s, - ) - if _north_table_is_valid(this_north_table, wtg_df=wtg_df): - min_step_size_for_run_optimize = 100 - run_optimize_north_offset = ( - (len(this_north_table) > len(prev_best_north_table)) - or (shift_step_size > min_step_size_for_run_optimize) - or (shift_step_size == 1) - ) - else: - this_north_table = prev_best_north_table - run_optimize_north_offset = False - this_north_table, this_score, this_wtg_df = _score_wtg_north_table( - wtg_north_table=this_north_table, - wtg_df=wtg_df, - improve_north_offset_col=run_optimize_north_offset, - north_ref_wd_col=north_ref_wd_col, - rated_power=rated_power, - timebase_s=timebase_s, - ) - return this_north_table, this_score, this_wtg_df - - -def _clip_wtg_north_table(initial_wtg_north_table: pd.DataFrame, *, wtg_df: pd.DataFrame) -> pd.DataFrame: - clipped_wtg_north_table = initial_wtg_north_table.copy() - - if clipped_wtg_north_table[TIMESTAMP_COL].min() < wtg_df.index.min(): - first_row_before_wf_df = ( - clipped_wtg_north_table.loc[clipped_wtg_north_table[TIMESTAMP_COL] <= wtg_df.index.min()] - .sort_values(by=[TIMESTAMP_COL], ascending=False) - .iloc[:1] - ) - clipped_wtg_north_table = pd.concat( - [ - first_row_before_wf_df, - clipped_wtg_north_table[clipped_wtg_north_table[TIMESTAMP_COL] > wtg_df.index.min()], - ], - ).reset_index(drop=True) - clipped_wtg_north_table.loc[0, TIMESTAMP_COL] = wtg_df.index.min() - - if clipped_wtg_north_table[TIMESTAMP_COL].min() > wtg_df.index.min(): - clipped_wtg_north_table.loc[0, TIMESTAMP_COL] = wtg_df.index.min() - - return clipped_wtg_north_table - - -def _prep_for_optimize_wtg_north_table( - wtg_df: pd.DataFrame, - *, - wtg_name: str, - north_ref_wd_col: str, - rated_power: float, - timebase_s: int, - plot_cfg: PlotConfig | None, - initial_wtg_north_table: pd.DataFrame | None = None, -) -> tuple[pd.DataFrame, pd.DataFrame]: - wtg_df = wtg_df.copy() - wtg_df = add_ok_yaw_col( - wtg_df, - new_col_name=f"ok_for_{north_ref_wd_col}_northing", - wd_col=north_ref_wd_col, - rated_power=rated_power, - timebase_s=timebase_s, - ) - wtg_df = _add_northed_ok_diff_and_rolling_cols( - wtg_df, north_ref_wd_col=north_ref_wd_col, timebase_s=timebase_s, north_offset=0 - ) - - initial_score = _northing_score( - wtg_df, - north_ref_wd_col=north_ref_wd_col, - changepoint_count=0, - rated_power=rated_power, - timebase_s=timebase_s, - ) - logger.info(f"\n\nwtg_name={wtg_name}, north_ref_wd_col={north_ref_wd_col}, initial_score={initial_score:.2f}") - - if plot_cfg is not None: - plot_yaw_diff_vs_power(wtg_df, wtg_name=wtg_name, north_ref_wd_col=north_ref_wd_col, plot_cfg=plot_cfg) - - if initial_wtg_north_table is None or len(initial_wtg_north_table) == 0: - wtg_north_table = pd.DataFrame( - data={TIMESTAMP_COL: wtg_df.index.min(), "north_offset": 0.0}, - index=[0], - ) - else: - initial_wtg_north_table = _clip_wtg_north_table( - initial_wtg_north_table, - wtg_df=wtg_df, - ) - - if not _north_table_is_valid(initial_wtg_north_table, wtg_df=wtg_df, check_north_offset=True): - msg = "initial_wtg_north_table is not valid" - raise ValueError(msg) - wtg_north_table = initial_wtg_north_table.copy() - - if not _north_table_is_valid(wtg_north_table, wtg_df=wtg_df, check_north_offset=True): - msg = "wtg_north_table is not valid" - raise RuntimeError(msg) - - return wtg_df, wtg_north_table - - -def _optimize_wtg_north_table( - *, - wtg_df: pd.DataFrame, - wtg_name: str, - rated_power: float, - north_ref_wd_col: str, - timebase_s: int, - plot_cfg: PlotConfig | None, - years_of_data: float, - initial_wtg_north_table: pd.DataFrame | None = None, - best_score_margin: float = 0, -) -> tuple[pd.DataFrame, pd.DataFrame, float, float]: - wtg_df = wtg_df.copy() - - wtg_df, wtg_north_table = _prep_for_optimize_wtg_north_table( - wtg_df, - wtg_name=wtg_name, - north_ref_wd_col=north_ref_wd_col, - rated_power=rated_power, - timebase_s=timebase_s, - plot_cfg=plot_cfg, - initial_wtg_north_table=initial_wtg_north_table, - ) - - loop_count = 0 - best_north_table, initial_score, best_wtg_df = _score_wtg_north_table( - wtg_north_table=wtg_north_table, - wtg_df=wtg_df, - improve_north_offset_col=True, - north_ref_wd_col=north_ref_wd_col, + """Estimate one turbine's north table against ``north_ref_wd_col``, from its raw yaw.""" + index = pd.DatetimeIndex(wtg_df.index) + reference = wtg_df[north_ref_wd_col].to_numpy(dtype=float) + usable = yaw_usable( + power=wtg_df[RAW_POWER_COL].to_numpy(dtype=float), + downtime_s=wtg_df[RAW_DOWNTIME_S_COL].to_numpy(dtype=float), + reference_deg=reference, rated_power=rated_power, timebase_s=timebase_s, ) - best_score = initial_score - logger.info(f"best_score={best_score:.2f} before optimization") - - if plot_cfg is not None: - plot_diff_to_north_ref_wd( - best_wtg_df, - wtg_name=wtg_name, - north_ref_wd_col=north_ref_wd_col, - loop_count=loop_count, - plot_cfg=plot_cfg, - ) - - done_optimizing = False - max_changepoints_to_add = min( - 5, _calc_max_changepoints_to_add(len(best_north_table), score=best_score, years_of_data=years_of_data) - ) - initial_step_size: int = 100 - shift_step_size: int = initial_step_size - tries_left: int = 1 - while not done_optimizing: - loop_count += 1 - best_move_found_this_loop = False - prev_best_north_table = best_north_table.copy() - prev_best_wtg_df = best_wtg_df.copy() - - do_changepoint_moves = max_changepoints_to_add > 0 - moves = _list_possible_moves( - prev_best_north_table, - do_changepoint_moves=do_changepoint_moves, - max_changepoints_to_add=max_changepoints_to_add, - ) - if do_changepoint_moves: - algo, timestamps = _get_changepoint_objects( - prev_best_wtg_df=prev_best_wtg_df, - north_ref_wd_col=north_ref_wd_col, - ) - - for move in moves: - this_north_table, this_score, this_wtg_df = _make_move_and_score_wtg_north_table( - move, - prev_best_north_table=prev_best_north_table, - wtg_df=wtg_df, - shift_step_size=shift_step_size, - north_ref_wd_col=north_ref_wd_col, - rated_power=rated_power, - timebase_s=timebase_s, - do_changepoint_moves=do_changepoint_moves, - algo=algo, - timestamps=timestamps, - ) - - if this_score < (best_score - best_score_margin): - best_north_table = this_north_table.copy() - best_score = this_score - best_wtg_df = this_wtg_df.copy() - best_move_found_this_loop = True - logger.info( - f"wtg_name={wtg_name}, best_score={this_score:.3f}, loop_count={loop_count}, " - f"shift_step_size={shift_step_size}, len(best_north_table)={len(best_north_table)}, " - f"move={move}", - ) - if len(best_north_table) == len(prev_best_north_table): - max_changepoints_to_add = 0 - else: - tries_left += 1 - logger.info(f"tries_left increased to {tries_left}") - max_changepoints_to_add = min( - 2, _calc_max_changepoints_to_add(len(best_north_table), score=best_score, years_of_data=years_of_data) - ) - if not best_move_found_this_loop: - shift_step_size = min(shift_step_size - 1, round(shift_step_size * DECAY_FRACTION)) - if shift_step_size < 1: - max_changepoints_to_add = min( - 2, - _calc_max_changepoints_to_add(len(best_north_table), score=best_score, years_of_data=years_of_data), - ) - shift_step_size = initial_step_size + 1 + (1 / (DECAY_FRACTION ** (math.pi * (tries_left + 1))) % 10) - shift_step_size = round(shift_step_size) - tries_left -= 1 - logger.info(f"tries_left decreased to {tries_left}") - if tries_left == 0: - done_optimizing = True - - wtg_north_table = best_north_table.copy() - wtg_df = _add_northed_ok_diff_and_rolling_cols( - wtg_df, - north_ref_wd_col=north_ref_wd_col, - timebase_s=timebase_s, - north_offset_df=wtg_north_table, + return estimate_north_table( + index, + wtg_df[RAW_YAWDIR_COL].to_numpy(dtype=float), + reference_deg=reference, + usable=usable, + settings=settings, ) - if plot_cfg is not None: - plot_diff_to_north_ref_wd( - wtg_df, - wtg_name=wtg_name, - north_ref_wd_col=north_ref_wd_col, - loop_count=loop_count, - plot_cfg=plot_cfg, - ) - - return wtg_north_table, wtg_df, initial_score, best_score -def _optimize_wf_north_table( +def _north_wf_table( wf_df: pd.DataFrame, *, north_ref_wd_col: str, cfg: WindUpConfig, plot_cfg: PlotConfig | None, - best_score_margin: float = 0, + settings: NorthingSettings = DEFAULT_NORTHING, ) -> pd.DataFrame: - optimized_wf_north_table = pd.DataFrame() - initial_wf_north_table = pd.DataFrame( - data=cfg.northing_corrections_utc, - columns=["TurbineName", TIMESTAMP_COL, "north_offset"], - ) + """Estimate every turbine's north table against ``north_ref_wd_col``, with v0's reporting.""" + wf_north_table = pd.DataFrame() for wtg_name in sorted(wf_df.index.unique(level="TurbineName").to_list()): wtg_obj = next(x for x in cfg.asset.wtgs if x.name == wtg_name) rated_power = wtg_obj.turbine_type.rated_power_kw - wtg_df = wf_df.loc[wtg_name].copy() + max_northing_error_before = check_wtg_northing( - wtg_df, - wtg_name=wtg_name, - north_ref_wd_col=north_ref_wd_col, - timebase_s=cfg.timebase_s, - plot_cfg=None, + wtg_df, wtg_name=wtg_name, north_ref_wd_col=north_ref_wd_col, timebase_s=cfg.timebase_s, plot_cfg=None ) - initial_wtg_north_table = initial_wf_north_table.loc[initial_wf_north_table["TurbineName"] == wtg_name] - changepoints_before = max(1, len(initial_wtg_north_table)) - wtg_north_table, optimized_wtg_df, score_before, score_after = _optimize_wtg_north_table( - wtg_df=wtg_df, - wtg_name=wtg_name, - rated_power=rated_power, + wtg_north_table = _wtg_north_table( + wtg_df, north_ref_wd_col=north_ref_wd_col, + rated_power=rated_power, timebase_s=cfg.timebase_s, - plot_cfg=plot_cfg, - initial_wtg_north_table=initial_wtg_north_table, - best_score_margin=best_score_margin, - years_of_data=(wtg_df.index.max() - wtg_df.index.min()).total_seconds() / (3600 * 24 * 365.25), + settings=settings, ) - northed_col = calc_northed_col_name(north_ref_wd_col) - optimized_wtg_df["YawAngleMean"] = optimized_wtg_df[northed_col] - max_northing_error_after = check_wtg_northing( - optimized_wtg_df, - wtg_name=wtg_name, - north_ref_wd_col=north_ref_wd_col, - timebase_s=cfg.timebase_s, - plot_cfg=plot_cfg, - ) + if plot_cfg is not None: + wtg_df = add_ok_yaw_col( + wtg_df, + new_col_name=f"ok_for_{north_ref_wd_col}_northing", + wd_col=north_ref_wd_col, + rated_power=rated_power, + timebase_s=cfg.timebase_s, + ) + before_df = _add_northed_ok_diff_and_rolling_cols( + wtg_df, north_ref_wd_col=north_ref_wd_col, timebase_s=cfg.timebase_s + ) + plot_yaw_diff_vs_power(before_df, wtg_name=wtg_name, north_ref_wd_col=north_ref_wd_col, plot_cfg=plot_cfg) + plot_diff_to_north_ref_wd( + before_df, wtg_name=wtg_name, north_ref_wd_col=north_ref_wd_col, loop_count=0, plot_cfg=plot_cfg + ) + after_df = _add_northed_ok_diff_and_rolling_cols( + wtg_df, north_ref_wd_col=north_ref_wd_col, timebase_s=cfg.timebase_s, north_table=wtg_north_table + ) + plot_diff_to_north_ref_wd( + after_df, wtg_name=wtg_name, north_ref_wd_col=north_ref_wd_col, loop_count=1, plot_cfg=plot_cfg + ) + after_df["YawAngleMean"] = after_df[calc_northed_col_name(north_ref_wd_col)] + max_northing_error_after = check_wtg_northing( + after_df, + wtg_name=wtg_name, + north_ref_wd_col=north_ref_wd_col, + timebase_s=cfg.timebase_s, + plot_cfg=plot_cfg, + ) + logger.info( + f"{wtg_name} max_northing_error changed from {max_northing_error_before:.1f} to " + f"{max_northing_error_after:.1f} [{max_northing_error_after - max_northing_error_before:.1f}]", + ) - changepoints_after = len(wtg_north_table) - logger.info( - f"changepoints changed from {changepoints_before} to {changepoints_after} " - f"[{changepoints_after - changepoints_before}]", - ) - logger.info( - f"northing score changed from {score_before:.1f} to {score_after:.1f} [{score_after - score_before:.1f}]" - ) - logger.info( - f"max_northing_error changed from {max_northing_error_before:.1f} to {max_northing_error_after:.1f} " - f"[{max_northing_error_after - max_northing_error_before:.1f}]", - ) + logger.info(f"{wtg_name} vs {north_ref_wd_col}: {len(wtg_north_table)} northing period(s)") logger.info(f"\n{wtg_north_table=}\n\n") + wtg_north_table = wtg_north_table.rename(columns={"timestamp": TIMESTAMP_COL}) wtg_north_table["TurbineName"] = wtg_name - optimized_wf_north_table = ( - pd.concat([optimized_wf_north_table, wtg_north_table]) + wf_north_table = ( + pd.concat([wf_north_table, wtg_north_table]) .sort_values(by=["TurbineName", TIMESTAMP_COL]) .reset_index(drop=True) ) - return optimized_wf_north_table + return wf_north_table def _write_northing_yaml(wf_north_table: pd.DataFrame, *, fpath: Path) -> None: + """Write a wind-farm north table as the YAML list ``northing_corrections_utc`` expects.""" north_table_for_yaml = wf_north_table.copy() - north_table_for_yaml[TIMESTAMP_COL] = north_table_for_yaml[TIMESTAMP_COL].dt.strftime( - "%Y-%m-%d %H:%M:%S", - ) - yaml_strings = [] - for _, row in north_table_for_yaml.iterrows(): - yaml_strings.append(f" - ['{row['TurbineName']}', {row[TIMESTAMP_COL]}, {row['north_offset']}]") - yaml_content = "\n".join(yaml_strings) + north_table_for_yaml[TIMESTAMP_COL] = north_table_for_yaml[TIMESTAMP_COL].dt.strftime("%Y-%m-%d %H:%M:%S") + yaml_strings = [ + f" - ['{row['TurbineName']}', {row[TIMESTAMP_COL]}, {row[NORTH_OFFSET_COL]}]" + for _, row in north_table_for_yaml.iterrows() + ] with fpath.open(mode="w") as yaml_file: - yaml_file.write(yaml_content) + yaml_file.write("\n".join(yaml_strings)) def auto_northing_corrections( @@ -709,22 +236,20 @@ def auto_northing_corrections( *, cfg: WindUpConfig, plot_cfg: PlotConfig | None, + settings: NorthingSettings = DEFAULT_NORTHING, ) -> pd.DataFrame: """Correct the northing of the wind farm to reanalysis data. :param wf_df: wind farm SCADA data :param cfg: wind farm configuration :param plot_cfg: plot configuration + :param settings: how the changepoint search is bounded; the default suits a farm record :return: wind farm SCADA data with corrected northing """ wf_df = wf_df.copy() - reanalysis_wf_north_table = _optimize_wf_north_table( - wf_df, - north_ref_wd_col=REANALYSIS_WD_COL, - cfg=cfg, - plot_cfg=plot_cfg, - best_score_margin=0.5, + reanalysis_wf_north_table = _north_wf_table( + wf_df, north_ref_wd_col=REANALYSIS_WD_COL, cfg=cfg, plot_cfg=plot_cfg, settings=against_reanalysis(settings) ) if plot_cfg is not None: reanalysis_wf_north_table.to_csv(cfg.out_dir / "reanalysis_wf_north_table.csv") @@ -743,11 +268,11 @@ def auto_northing_corrections( if plot_cfg is not None: plot_wf_yawdir_and_reanalysis_timeseries(wf_df, cfg=cfg, plot_cfg=plot_cfg) - optimized_northing_corrections = _optimize_wf_north_table( - wf_df, - north_ref_wd_col=WINDFARM_YAWDIR_COL, - cfg=cfg, - plot_cfg=plot_cfg, + farm_settings = settings if _farm_reference_is_independent(wf_df) else against_reanalysis(settings) + if farm_settings is not settings: + logger.info("wind farm yaw direction fell back to reanalysis; northing conservatively") + optimized_northing_corrections = _north_wf_table( + wf_df, north_ref_wd_col=WINDFARM_YAWDIR_COL, cfg=cfg, plot_cfg=plot_cfg, settings=farm_settings ) if plot_cfg is not None: optimized_northing_corrections.to_csv(cfg.out_dir / "optimized_northing_corrections.csv") diff --git a/tests/benchmarking/baselines/test_power_model_features.py b/tests/benchmarking/baselines/test_power_model_features.py index 80405f7f..263b8d68 100644 --- a/tests/benchmarking/baselines/test_power_model_features.py +++ b/tests/benchmarking/baselines/test_power_model_features.py @@ -182,3 +182,99 @@ def test_guard_rejects_test_turbine_feature(self) -> None: def test_guard_passes_for_reference_only_features(self) -> None: check_reference_only([f"{_POWER}{QUALIFIER}R1", "temperature_2m"], test_wtg="T1") + + +_NORTHED_DIR = "northed_wtc_NacPos_mean" +_RAW_DIR = "wtc_NacPos_mean" + + +def _scada_with_direction(idx: pd.DatetimeIndex) -> pd.DataFrame: + """The long SCADA above, plus a raw nacelle position and its northed counterpart.""" + scada = _scada(idx) + rng = np.random.default_rng(1) + scada[_RAW_DIR] = rng.uniform(0, 360, len(scada)) + scada[_NORTHED_DIR] = (scada[_RAW_DIR] + 30.0) % 360.0 + return scada + + +class TestReferenceDirectionFeature: + """Each reference's northed direction enters as sin/cos; the raw column never does.""" + + def test_direction_enters_as_sin_and_cos_per_reference(self) -> None: + idx = _index(12) + feats = build_reference_features( + _scada_with_direction(idx), + test_wtg="T1", + turbine_col=_TURBINE, + active_power_col=_POWER, + availability_col=_AVAIL, + direction_col=_NORTHED_DIR, + ) + for ref in ("R1", "R2", "R3"): + assert f"{_NORTHED_DIR}_sin{QUALIFIER}{ref}" in feats.columns + assert f"{_NORTHED_DIR}_cos{QUALIFIER}{ref}" in feats.columns + # the raw degree column is not a feature: LightGBM cannot see that 359 deg is next to 1 deg + assert not any(c.startswith(f"{_NORTHED_DIR}{QUALIFIER}") for c in feats.columns) + assert not any(c.startswith(_RAW_DIR) for c in feats.columns) + + def test_sin_cos_values_are_the_direction_on_the_unit_circle(self) -> None: + idx = _index(6) + scada = _scada_with_direction(idx) + feats = build_reference_features( + scada, + test_wtg="T1", + turbine_col=_TURBINE, + active_power_col=_POWER, + availability_col=_AVAIL, + direction_col=_NORTHED_DIR, + ) + expected = scada[scada[_TURBINE] == "R1"][_NORTHED_DIR].to_numpy(dtype=float) + assert feats[f"{_NORTHED_DIR}_sin{QUALIFIER}R1"].to_numpy() == pytest.approx(np.sin(np.deg2rad(expected))) + assert feats[f"{_NORTHED_DIR}_cos{QUALIFIER}R1"].to_numpy() == pytest.approx(np.cos(np.deg2rad(expected))) + + def test_test_turbine_direction_is_never_a_feature(self) -> None: + idx = _index(12) + feats = build_reference_features( + _scada_with_direction(idx), + test_wtg="T1", + turbine_col=_TURBINE, + active_power_col=_POWER, + availability_col=_AVAIL, + direction_col=_NORTHED_DIR, + ) + assert not any(c.endswith(f"{QUALIFIER}T1") for c in feats.columns) + + def test_missing_northed_column_raises_naming_the_shared_step(self) -> None: + idx = _index(12) + with pytest.raises(ValueError, match=_NORTHED_DIR): + build_reference_features( + _scada(idx), # no direction columns at all + test_wtg="T1", + turbine_col=_TURBINE, + active_power_col=_POWER, + availability_col=_AVAIL, + direction_col=_NORTHED_DIR, + ) + + def test_a_raw_direction_in_extra_cols_is_dropped_for_the_northed_one(self) -> None: + idx = _index(12) + feats = build_reference_features( + _scada_with_direction(idx), + test_wtg="T1", + turbine_col=_TURBINE, + active_power_col=_POWER, + availability_col=_AVAIL, + extra_cols=(_RAW_DIR,), + direction_col=_NORTHED_DIR, + ) + assert not any(c.startswith(_RAW_DIR) for c in feats.columns) + assert any(c.startswith(f"{_NORTHED_DIR}_sin") for c in feats.columns) + + def test_omitting_direction_col_keeps_the_previous_feature_set(self) -> None: + idx = _index(12) + scada = _scada_with_direction(idx) + without = build_reference_features( + scada, test_wtg="T1", turbine_col=_TURBINE, active_power_col=_POWER, availability_col=_AVAIL + ) + assert len(without.columns) == 6 + assert not any("northed" in c for c in without.columns) diff --git a/tests/benchmarking/campaigns/test_northing.py b/tests/benchmarking/campaigns/test_northing.py new file mode 100644 index 00000000..7ed93741 --- /dev/null +++ b/tests/benchmarking/campaigns/test_northing.py @@ -0,0 +1,151 @@ +"""Tests for the shared northing step that runs upstream of every method.""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from benchmarking.campaigns.declaration import CampaignSpec +from benchmarking.campaigns.northing import north_campaign_scada +from benchmarking.synthetic import HOT_COLUMNS +from wind_up.circular_math import circ_diff + +_COLUMNS = HOT_COLUMNS +_TURBINES = ("T01", "T02", "T03", "T04") +_START = pd.Timestamp("2018-01-01", tz="UTC") +_RATED = 2300.0 + + +def _index(days: int = 120) -> pd.DatetimeIndex: + return pd.date_range(start=_START, periods=days * 144, freq="600s", tz="UTC") + + +def _scada( + index: pd.DatetimeIndex, offsets: dict[str, list[tuple[pd.Timestamp, float]]] +) -> tuple[pd.DataFrame, np.ndarray]: + """Long SCADA whose turbines report the site direction minus their own north offset.""" + rng = np.random.default_rng(0) + site_wd = np.cumsum(rng.normal(0.0, 2.0, len(index))) % 360.0 + frames = [] + for i, turbine in enumerate(_TURBINES): + applied = np.full(len(index), offsets[turbine][0][1], dtype=float) + for when, value in offsets[turbine][1:]: + applied[index >= when] = value + scatter = np.random.default_rng(10 + i).normal(0.0, 6.0, len(index)) + frames.append( + pd.DataFrame( + { + _COLUMNS.turbine: turbine, + _COLUMNS.active_power: 1200.0, + _COLUMNS.active_power_min: 1100.0, + _COLUMNS.wind_speed: 9.0, + _COLUMNS.wind_speed_sd: 1.0, + _COLUMNS.gen_rpm: 1500.0, + _COLUMNS.availability: 600.0, + _COLUMNS.nacelle_position: (site_wd + scatter - applied) % 360.0, + }, + index=index, + ) + ) + return pd.concat(frames), site_wd + + +def _spec(north_offsets: list[tuple[str, pd.Timestamp, float]] | None) -> CampaignSpec: + return CampaignSpec( + upgraded_turbines=["T01"], + upgrade_timing=_START + pd.Timedelta(days=60), + candidate_references=[t for t in _TURBINES if t != "T01"], + excluded_turbines=[], + coords=dict.fromkeys(_TURBINES, (0.0, 0.0)), + north_offsets=north_offsets, + rated_power_kw=_RATED, + analysis_period=(_START, _START + pd.Timedelta(days=120)), + ) + + +def _northed(frame: pd.DataFrame, turbine: str) -> np.ndarray: + rows = frame[frame[_COLUMNS.turbine] == turbine] + return rows[_COLUMNS.northed("nacelle_position")].to_numpy(dtype=float) + + +class TestDiscovery: + """``north_offsets=None`` means discover from the data.""" + + def test_writes_a_northed_companion_leaving_the_original_untouched(self) -> None: + index = _index() + offsets = {t: [(_START, 20.0 * i)] for i, t in enumerate(_TURBINES)} + scada, site_wd = _scada(index, offsets) + era5 = pd.Series(site_wd, index=index) + + out = north_campaign_scada(scada, spec=_spec(None), columns=_COLUMNS, era5_wd=era5) + + assert _COLUMNS.northed("nacelle_position") in out.columns + assert np.allclose( + out[_COLUMNS.nacelle_position].to_numpy(dtype=float), + scada[_COLUMNS.nacelle_position].to_numpy(dtype=float), + ) + + def test_recovers_each_turbines_offset(self) -> None: + index = _index() + offsets = {"T01": [(_START, 0.0)], "T02": [(_START, 25.0)], "T03": [(_START, -40.0)], "T04": [(_START, 12.0)]} + scada, site_wd = _scada(index, offsets) + era5 = pd.Series(site_wd, index=index) + + out = north_campaign_scada(scada, spec=_spec(None), columns=_COLUMNS, era5_wd=era5) + + for turbine in _TURBINES: + assert circ_diff(_northed(out, turbine), site_wd).mean() == pytest.approx(0.0, abs=2.0), turbine + + def test_recovers_a_step_change_mid_campaign(self) -> None: + index = _index() + step_at = _START + pd.Timedelta(days=70) + offsets = {t: [(_START, 5.0 * i)] for i, t in enumerate(_TURBINES)} + offsets["T03"] = [(_START, 10.0), (step_at, 55.0)] + scada, site_wd = _scada(index, offsets) + era5 = pd.Series(site_wd, index=index) + + out = north_campaign_scada(scada, spec=_spec(None), columns=_COLUMNS, era5_wd=era5) + + assert circ_diff(_northed(out, "T03"), site_wd).mean() == pytest.approx(0.0, abs=2.0) + + def test_discovery_without_reanalysis_raises(self) -> None: + index = _index(days=30) + scada, _ = _scada(index, {t: [(_START, 0.0)] for t in _TURBINES}) + with pytest.raises(ValueError, match="era5_wd"): + north_campaign_scada(scada, spec=_spec(None), columns=_COLUMNS, era5_wd=None) + + +class TestDeclared: + """A supplied table is applied exactly; nothing is discovered.""" + + def test_applies_the_declared_offsets(self) -> None: + index = _index(days=30) + scada, _ = _scada(index, {t: [(_START, 0.0)] for t in _TURBINES}) + declared = [("T02", _START, 33.0)] + + out = north_campaign_scada(scada, spec=_spec(declared), columns=_COLUMNS, era5_wd=None) + + raw = scada[scada[_COLUMNS.turbine] == "T02"][_COLUMNS.nacelle_position].to_numpy(dtype=float) + assert _northed(out, "T02") == pytest.approx((raw + 33.0) % 360.0) + # a turbine with no declared correction is copied through unchanged + untouched = scada[scada[_COLUMNS.turbine] == "T01"][_COLUMNS.nacelle_position].to_numpy(dtype=float) + assert _northed(out, "T01") == pytest.approx(untouched % 360.0) + + def test_an_empty_list_applies_no_correction_but_still_writes_the_column(self) -> None: + index = _index(days=30) + offsets = {t: [(_START, 30.0)] for t in _TURBINES} + scada, _ = _scada(index, offsets) + + out = north_campaign_scada(scada, spec=_spec([]), columns=_COLUMNS, era5_wd=None) + + assert _COLUMNS.northed("nacelle_position") in out.columns + for turbine in _TURBINES: + raw = scada[scada[_COLUMNS.turbine] == turbine][_COLUMNS.nacelle_position].to_numpy(dtype=float) + assert _northed(out, turbine) == pytest.approx(raw % 360.0), turbine + + def test_an_empty_list_needs_no_reanalysis(self) -> None: + index = _index(days=30) + scada, _ = _scada(index, {t: [(_START, 0.0)] for t in _TURBINES}) + # would raise if this branch tried to discover + north_campaign_scada(scada, spec=_spec([]), columns=_COLUMNS, era5_wd=None) diff --git a/tests/benchmarking/campaigns/test_northing_fixture.py b/tests/benchmarking/campaigns/test_northing_fixture.py new file mode 100644 index 00000000..729c8e77 --- /dev/null +++ b/tests/benchmarking/campaigns/test_northing_fixture.py @@ -0,0 +1,133 @@ +"""Tests for the R1 northing fixture's declaration and its bites/fixed verdicts. + +The fixture's actual runs are a driver (they need the Hill of Towie download and the power +model); what is unit-tested here is that each cell of the 2x2 is declared as intended and that +the verdict arithmetic says what the acceptance thresholds mean. +""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from benchmarking.campaigns.northing_fixture import ( + CAMPAIGN_START, + FAULT_OFFSET_DEG, + FAULT_TURBINE, + FIXTURE_REFERENCES, + FIXTURE_TEST_WTG, + analysis_period, + fault_time, + fixture_campaign, + verdict_table, +) + + +class TestFixtureCampaign: + @pytest.mark.parametrize("mode", ["prepost", "toggle"]) + def test_the_test_turbine_is_never_a_reference(self, mode: str) -> None: + campaign = fixture_campaign(mode, faulted=True, northing=True) + assert campaign.upgraded_turbines == [FIXTURE_TEST_WTG] + assert FIXTURE_TEST_WTG not in campaign.candidate_references + assert set(campaign.candidate_references) == set(FIXTURE_REFERENCES) + + def test_t05_is_not_in_the_fixture(self) -> None: + """T05 is T06's nearest neighbour but carries real northing steps over 2017-2018.""" + campaign = fixture_campaign("prepost", faulted=False, northing=True) + assert "T05" not in campaign.turbines + + @pytest.mark.parametrize("mode", ["prepost", "toggle"]) + def test_faulted_injects_one_step_on_a_reference(self, mode: str) -> None: + campaign = fixture_campaign(mode, faulted=True, northing=True) + assert len(campaign.faults) == 1 + fault = campaign.faults[0] + # the fault must land on a reference: both v0 and power_model key on reference direction + assert fault.turbine in FIXTURE_REFERENCES + assert fault.turbine == FAULT_TURBINE + assert fault.offset_deg == FAULT_OFFSET_DEG + assert fault.at == fault_time(mode) + + @pytest.mark.parametrize("mode", ["prepost", "toggle"]) + def test_clean_injects_no_fault(self, mode: str) -> None: + assert fixture_campaign(mode, faulted=False, northing=True).faults == [] + + def test_northing_on_leaves_offsets_undeclared_so_they_are_discovered(self) -> None: + assert fixture_campaign("prepost", faulted=True, northing=True).north_offsets is None + + def test_northing_off_declares_an_empty_table_rather_than_none(self) -> None: + """An empty list means "apply exactly these" -- so the northed column is an uncorrected copy.""" + assert fixture_campaign("prepost", faulted=True, northing=False).north_offsets == [] + + def test_the_fault_never_reaches_the_public_spec(self) -> None: + spec = fixture_campaign("prepost", faulted=True, northing=True).spec() + assert not hasattr(spec, "faults") + assert spec.north_offsets is None + + def test_prepost_faults_at_the_changeover(self) -> None: + assert fault_time("prepost") == CAMPAIGN_START + + def test_toggle_faults_mid_campaign(self) -> None: + _, end = analysis_period("toggle") + assert CAMPAIGN_START < fault_time("toggle") < end + + def test_an_unknown_mode_raises(self) -> None: + with pytest.raises(ValueError, match="unknown mode"): + fixture_campaign("sideways", faulted=False, northing=True) + + +def _table(errors: dict[tuple[bool, bool], float]) -> pd.DataFrame: + """A one-mode, one-method 2x2 from ``{(faulted, northing): signed_error_fraction}``.""" + return pd.DataFrame( + [ + { + "mode": "prepost", + "method": "power_model", + "faulted": faulted, + "northing": northing, + "signed_error": error, + } + for (faulted, northing), error in errors.items() + ] + ) + + +class TestVerdictTable: + def test_a_fault_that_bites_and_is_fixed_passes_all_three(self) -> None: + verdicts = verdict_table( + _table({(False, False): 0.003, (True, False): 0.030, (False, True): 0.003, (True, True): 0.004}) + ) + row = verdicts.iloc[0] + assert row["bites_pp"] == pytest.approx(2.7) + assert bool(row["bites"]) + assert bool(row["fixed"]) + assert bool(row["no_harm"]) + + def test_a_fault_too_small_to_bite_is_reported_as_such(self) -> None: + verdicts = verdict_table( + _table({(False, False): 0.003, (True, False): 0.008, (False, True): 0.003, (True, True): 0.003}) + ) + assert not bool(verdicts.iloc[0]["bites"]) + + def test_northing_that_does_not_close_the_gap_fails_fixed(self) -> None: + verdicts = verdict_table( + _table({(False, False): 0.003, (True, False): 0.030, (False, True): 0.003, (True, True): 0.020}) + ) + assert bool(verdicts.iloc[0]["bites"]) + assert not bool(verdicts.iloc[0]["fixed"]) + + def test_northing_that_hurts_clean_data_fails_no_harm(self) -> None: + verdicts = verdict_table( + _table({(False, False): 0.003, (True, False): 0.030, (False, True): 0.012, (True, True): 0.004}) + ) + assert not bool(verdicts.iloc[0]["no_harm"]) + + def test_the_sign_of_the_error_does_not_matter_only_its_size(self) -> None: + verdicts = verdict_table( + _table({(False, False): 0.003, (True, False): -0.030, (False, True): 0.003, (True, True): -0.004}) + ) + assert bool(verdicts.iloc[0]["bites"]) + assert bool(verdicts.iloc[0]["fixed"]) + + def test_an_incomplete_two_by_two_is_skipped_rather_than_half_judged(self) -> None: + partial = _table({(False, False): 0.003, (True, False): 0.030}) + assert verdict_table(partial).empty diff --git a/tests/benchmarking/synthetic/test_faults.py b/tests/benchmarking/synthetic/test_faults.py new file mode 100644 index 00000000..0e787442 --- /dev/null +++ b/tests/benchmarking/synthetic/test_faults.py @@ -0,0 +1,144 @@ +"""Tests for injected data faults: measurement corruptions that leave ground truth alone.""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from benchmarking.synthetic import HOT_COLUMNS, ConstantCpChange, NorthingStep, generate_dataset + +_COLUMNS = HOT_COLUMNS +_TURBINES = ("T01", "T02", "T03") +_START = pd.Timestamp("2018-01-01", tz="UTC") +_CHANGEOVER = _START + pd.Timedelta(days=30) +_FAULT_AT = _START + pd.Timedelta(days=45) + + +def _index(days: int = 60) -> pd.DatetimeIndex: + return pd.date_range(start=_START, periods=days * 24, freq="3600s", tz="UTC") + + +def _scada(index: pd.DatetimeIndex) -> pd.DataFrame: + rng = np.random.default_rng(0) + frames = [ + pd.DataFrame( + { + _COLUMNS.turbine: turbine, + _COLUMNS.active_power: rng.uniform(200, 2000, len(index)), + _COLUMNS.active_power_min: 100.0, + _COLUMNS.wind_speed: rng.uniform(4, 14, len(index)), + _COLUMNS.wind_speed_sd: 1.0, + _COLUMNS.gen_rpm: 1400.0, + _COLUMNS.availability: 3600.0, + _COLUMNS.nacelle_position: rng.uniform(0, 360, len(index)), + }, + index=index, + ) + for turbine in _TURBINES + ] + return pd.concat(frames) + + +def _generate(faults: list) -> tuple: + index = _index() + scada = _scada(index) + dataset = generate_dataset( + scada_df=scada, + test_wtgs=["T01"], + upgrades=[ConstantCpChange(delta=0.05)], + mode="prepost", + upgrade_timing=_CHANGEOVER, + faults=faults, + columns=_COLUMNS, + ) + return dataset, scada + + +def _direction(frame: pd.DataFrame, turbine: str) -> pd.Series: + rows = frame[frame[_COLUMNS.turbine] == turbine] + return rows[_COLUMNS.nacelle_position] + + +class TestNorthingStep: + def test_shifts_the_named_turbines_direction_from_the_step_date(self) -> None: + dataset, scada = _generate([NorthingStep(turbine="T02", at=_FAULT_AT, offset_deg=40.0)]) + + before = _direction(dataset.synthetic_df, "T02").loc[:_FAULT_AT].iloc[:-1] + clean_before = _direction(scada, "T02").loc[:_FAULT_AT].iloc[:-1] + assert before.to_numpy() == pytest.approx(clean_before.to_numpy()) + + after = _direction(dataset.synthetic_df, "T02").loc[_FAULT_AT:] + clean_after = _direction(scada, "T02").loc[_FAULT_AT:] + assert after.to_numpy() == pytest.approx((clean_after.to_numpy() + 40.0) % 360.0) + + def test_leaves_other_turbines_alone(self) -> None: + dataset, scada = _generate([NorthingStep(turbine="T02", at=_FAULT_AT, offset_deg=40.0)]) + for turbine in ("T01", "T03"): + assert _direction(dataset.synthetic_df, turbine).to_numpy() == pytest.approx( + _direction(scada, turbine).to_numpy() + ), turbine + + def test_changes_no_power_so_the_true_uplift_is_untouched(self) -> None: + """The fault is a measurement corruption; ground truth must not move at all.""" + clean, _ = _generate([]) + faulted, _ = _generate([NorthingStep(turbine="T02", at=_FAULT_AT, offset_deg=40.0)]) + + assert faulted.true_uplift().overall == pytest.approx(clean.true_uplift().overall, abs=1e-12) + assert faulted.synthetic_df[_COLUMNS.active_power].to_numpy() == pytest.approx( + clean.synthetic_df[_COLUMNS.active_power].to_numpy() + ) + + def test_the_untouched_original_never_carries_the_fault(self) -> None: + """``original_df`` is the truth reference: a method's corrupted view must not reach it.""" + dataset, scada = _generate([NorthingStep(turbine="T02", at=_FAULT_AT, offset_deg=40.0)]) + assert _direction(dataset.original_df, "T02").to_numpy() == pytest.approx(_direction(scada, "T02").to_numpy()) + + def test_wraps_past_360(self) -> None: + dataset, scada = _generate([NorthingStep(turbine="T02", at=_START, offset_deg=350.0)]) + got = _direction(dataset.synthetic_df, "T02").to_numpy() + assert got.min() >= 0.0 + assert got.max() < 360.0 + assert got == pytest.approx((_direction(scada, "T02").to_numpy() + 350.0) % 360.0) + + def test_several_faults_compose(self) -> None: + dataset, scada = _generate( + [ + NorthingStep(turbine="T02", at=_FAULT_AT, offset_deg=40.0), + NorthingStep(turbine="T03", at=_FAULT_AT, offset_deg=-25.0), + ] + ) + after = _direction(dataset.synthetic_df, "T03").loc[_FAULT_AT:].to_numpy() + clean = _direction(scada, "T03").loc[_FAULT_AT:].to_numpy() + assert after == pytest.approx((clean - 25.0) % 360.0) + + def test_is_recorded_in_run_metadata(self) -> None: + dataset, _ = _generate([NorthingStep(turbine="T02", at=_FAULT_AT, offset_deg=40.0)]) + assert dataset.run_metadata["faults"] == [ + {"kind": "northing_step", "turbine": "T02", "at": str(_FAULT_AT), "offset_deg": 40.0} + ] + + def test_no_faults_is_the_default_and_records_an_empty_list(self) -> None: + dataset, scada = _generate([]) + assert dataset.run_metadata["faults"] == [] + assert dataset.synthetic_df[_COLUMNS.nacelle_position].to_numpy() == pytest.approx( + scada[_COLUMNS.nacelle_position].to_numpy() + ) + + def test_an_unknown_turbine_raises(self) -> None: + with pytest.raises(ValueError, match="T99"): + _generate([NorthingStep(turbine="T99", at=_FAULT_AT, offset_deg=40.0)]) + + def test_a_missing_direction_column_raises_naming_the_role(self) -> None: + index = _index(days=10) + scada = _scada(index).drop(columns=[_COLUMNS.nacelle_position]) + with pytest.raises(ValueError, match=_COLUMNS.nacelle_position): + generate_dataset( + scada_df=scada, + test_wtgs=["T01"], + upgrades=[], + mode="prepost", + upgrade_timing=_CHANGEOVER, + faults=[NorthingStep(turbine="T02", at=_FAULT_AT, offset_deg=40.0)], + columns=_COLUMNS, + ) diff --git a/tests/test_optimize_northing.py b/tests/test_optimize_northing.py index 26a8ebea..dbacb03b 100644 --- a/tests/test_optimize_northing.py +++ b/tests/test_optimize_northing.py @@ -3,109 +3,14 @@ import numpy as np import pandas as pd import pytest -from pandas.testing import assert_frame_equal from tests.conftest import TEST_DATA_FLD -from wind_up_v0.circular_math import circ_median -from wind_up_v0.constants import RAW_DOWNTIME_S_COL, RAW_POWER_COL, RAW_YAWDIR_COL, TIMESTAMP_COL +from wind_up.circular_math import circ_median +from wind_up_v0.constants import RAW_DOWNTIME_S_COL, RAW_POWER_COL, RAW_YAWDIR_COL from wind_up_v0.models import WindUpConfig -from wind_up_v0.optimize_northing import _clip_wtg_north_table, auto_northing_corrections +from wind_up_v0.optimize_northing import auto_northing_corrections from wind_up_v0.reanalysis_data import ReanalysisDataset, add_reanalysis_data - -def test_clip_wtg_north_table_entries_before() -> None: - tstamps = pd.date_range(start="2021-01-01", tz="UTC", periods=3, freq="10min") - idx = pd.Index(tstamps) - wtg_df = pd.DataFrame( - data={ - "ActivePowerMean": [3.14] * 3, - "some_col_with_nans": [np.nan] * 3, - }, - index=idx, - ) - tstamps_for_wtg_north_table = [ - tstamps[0] - pd.Timedelta(days=2), - tstamps[0] - pd.Timedelta(days=1), - tstamps[-1], - tstamps[-1] + pd.Timedelta(days=1), - ] - initial_wtg_north_table = pd.DataFrame( - data={ - TIMESTAMP_COL: tstamps_for_wtg_north_table, - "north_offset": list(range(len(tstamps_for_wtg_north_table))), - }, - ) - expected_wtg_north_table = pd.DataFrame( - data={ - TIMESTAMP_COL: [tstamps[0], tstamps[-1], tstamps[-1] + pd.Timedelta(days=1)], - "north_offset": [1, 2, 3], - }, - ) - actual_wtg_north_table = _clip_wtg_north_table(initial_wtg_north_table, wtg_df=wtg_df) - assert_frame_equal(actual_wtg_north_table, expected_wtg_north_table) - - -def test_clip_wtg_north_table_entry_exactly_at_start() -> None: - tstamps = pd.date_range(start="2021-01-01", tz="UTC", periods=3, freq="10min") - idx = pd.Index(tstamps) - wtg_df = pd.DataFrame( - data={ - "ActivePowerMean": [3.14] * 3, - "some_col_with_nans": [np.nan] * 3, - }, - index=idx, - ) - tstamps_for_wtg_north_table = [ - tstamps[0] - pd.Timedelta(days=1), - tstamps[0], - tstamps[-1], - tstamps[-1] + pd.Timedelta(days=1), - ] - initial_wtg_north_table = pd.DataFrame( - data={ - TIMESTAMP_COL: tstamps_for_wtg_north_table, - "north_offset": list(range(len(tstamps_for_wtg_north_table))), - }, - ) - expected_wtg_north_table = pd.DataFrame( - data={ - TIMESTAMP_COL: [tstamps[0], tstamps[-1], tstamps[-1] + pd.Timedelta(days=1)], - "north_offset": [1, 2, 3], - }, - ) - actual_wtg_north_table = _clip_wtg_north_table(initial_wtg_north_table, wtg_df=wtg_df) - assert_frame_equal(actual_wtg_north_table, expected_wtg_north_table) - - -def test_clip_wtg_north_table_entry_after_start() -> None: - tstamps = pd.date_range(start="2021-01-01", tz="UTC", periods=3, freq="10min") - idx = pd.Index(tstamps) - wtg_df = pd.DataFrame( - data={ - "ActivePowerMean": [3.14] * 3, - "some_col_with_nans": [np.nan] * 3, - }, - index=idx, - ) - tstamps_for_wtg_north_table = [ - tstamps[-1] + pd.Timedelta(days=1), - ] - initial_wtg_north_table = pd.DataFrame( - data={ - TIMESTAMP_COL: tstamps_for_wtg_north_table, - "north_offset": list(range(len(tstamps_for_wtg_north_table))), - }, - ) - expected_wtg_north_table = pd.DataFrame( - data={ - TIMESTAMP_COL: [tstamps[0]], - "north_offset": [0], - }, - ) - actual_wtg_north_table = _clip_wtg_north_table(initial_wtg_north_table, wtg_df=wtg_df) - assert_frame_equal(actual_wtg_north_table, expected_wtg_north_table) - - wind_direction_offsets = [ 0, 343, # chosen to create lots of 0-360 wraps in the original data @@ -113,12 +18,8 @@ def test_clip_wtg_north_table_entry_after_start() -> None: ] -@pytest.mark.slow -@pytest.mark.parametrize(("wind_direction_offset"), wind_direction_offsets) -def test_auto_northing_corrections(test_homer_config: WindUpConfig, wind_direction_offset: float) -> None: - cfg = test_homer_config - cfg.lt_first_dt_utc_start = pd.Timestamp("2023-07-01 00:00:00", tz="UTC") - cfg.analysis_last_dt_utc_start = pd.Timestamp("2023-07-31 23:50:00", tz="UTC") +def _homer_wf_df(cfg: WindUpConfig, *, wind_direction_offset: float) -> pd.DataFrame: + """The July 2023 Homer month, with every direction column rotated by ``wind_direction_offset``.""" wf_df = pd.read_parquet(Path(__file__).parents[0] / "test_data/Homer Wind Farm_July2023_scada_improved.parquet") reanalysis_datasets = [ ReanalysisDataset(id=fp.stem, data=pd.read_parquet(fp)) @@ -131,18 +32,31 @@ def test_auto_northing_corrections(test_homer_config: WindUpConfig, wind_directi wf_df[RAW_YAWDIR_COL] = wf_df["YawAngleMean"] wf_df[RAW_DOWNTIME_S_COL] = wf_df["ShutdownDuration"] - # add wind_direction_offset to direction columns for col in {RAW_YAWDIR_COL, "YawAngleMean", "reanalysis_wd"}: wf_df[col] = (wf_df[col] + wind_direction_offset) % 360 if wind_direction_offset != 0: # in this case YawAngleMin and YawAngleMax will be incorrect, so nan them out wf_df["YawAngleMin"] = np.nan wf_df["YawAngleMax"] = np.nan + return wf_df + + +def _median_yaw(wf_df: pd.DataFrame) -> pd.Series: + return wf_df.groupby("TurbineName", observed=True)["YawAngleMean"].apply(circ_median) + + +@pytest.mark.slow +@pytest.mark.parametrize(("wind_direction_offset"), wind_direction_offsets) +def test_auto_northing_corrections(test_homer_config: WindUpConfig, wind_direction_offset: float) -> None: + cfg = test_homer_config + cfg.lt_first_dt_utc_start = pd.Timestamp("2023-07-01 00:00:00", tz="UTC") + cfg.analysis_last_dt_utc_start = pd.Timestamp("2023-07-31 23:50:00", tz="UTC") + wf_df = _homer_wf_df(cfg, wind_direction_offset=wind_direction_offset) northed_wf_df = auto_northing_corrections(wf_df, cfg=cfg, plot_cfg=None) - median_yaw_before_northing = wf_df.groupby("TurbineName", observed=True)["YawAngleMean"].apply(circ_median) - median_yaw_after_northing = northed_wf_df.groupby("TurbineName", observed=True)["YawAngleMean"].apply(circ_median) + median_yaw_before_northing = _median_yaw(wf_df) + median_yaw_after_northing = _median_yaw(northed_wf_df) expected_t1_yaw_after_northing = (290 + wind_direction_offset) % 360 expected_t2_yaw_after_northing = (295 + wind_direction_offset) % 360 @@ -151,7 +65,22 @@ def test_auto_northing_corrections(test_homer_config: WindUpConfig, wind_directi assert median_yaw_after_northing["HMR_T01"] == pytest.approx(expected_t1_yaw_after_northing, abs=1.0) assert median_yaw_after_northing["HMR_T02"] == pytest.approx(expected_t2_yaw_after_northing, abs=1.0) - # try to mess up the yaw angles further and run again + +@pytest.mark.slow +@pytest.mark.parametrize(("wind_direction_offset"), wind_direction_offsets) +def test_auto_northing_corrections_with_changepoints( + test_homer_config: WindUpConfig, wind_direction_offset: float +) -> None: + """Two injected step changes per turbine, on top of a 180 degree rotation, are all recovered. + + A month is short enough that the per-year rate alone would allow only one changepoint; the + settings' floor is what leaves room for both. + """ + cfg = test_homer_config + cfg.lt_first_dt_utc_start = pd.Timestamp("2023-07-01 00:00:00", tz="UTC") + cfg.analysis_last_dt_utc_start = pd.Timestamp("2023-07-31 23:50:00", tz="UTC") + wf_df = _homer_wf_df(cfg, wind_direction_offset=wind_direction_offset) + wf_df[RAW_YAWDIR_COL] = (wf_df[RAW_YAWDIR_COL] + 180) % 360 # add a change point in for each turbine @@ -169,6 +98,6 @@ def test_auto_northing_corrections(test_homer_config: WindUpConfig, wind_directi northed_wf_df = auto_northing_corrections(wf_df, cfg=cfg, plot_cfg=None) - median_yaw_after_northing = northed_wf_df.groupby("TurbineName", observed=True)["YawAngleMean"].apply(circ_median) - assert median_yaw_after_northing["HMR_T01"] == pytest.approx(expected_t1_yaw_after_northing, abs=1.5) - assert median_yaw_after_northing["HMR_T02"] == pytest.approx(expected_t2_yaw_after_northing, abs=1.5) + median_yaw_after_northing = _median_yaw(northed_wf_df) + assert median_yaw_after_northing["HMR_T01"] == pytest.approx((290 + wind_direction_offset) % 360, abs=1.5) + assert median_yaw_after_northing["HMR_T02"] == pytest.approx((295 + wind_direction_offset) % 360, abs=1.5) diff --git a/tests/wind_up/test_northing.py b/tests/wind_up/test_northing.py new file mode 100644 index 00000000..1c380134 --- /dev/null +++ b/tests/wind_up/test_northing.py @@ -0,0 +1,504 @@ +"""Tests for the northing estimator core.""" + +from __future__ import annotations + +from dataclasses import replace + +import numpy as np +import pandas as pd +import pytest + +from wind_up.circular_math import circ_diff +from wind_up.northing import ( + DEFAULT_NORTHING, + NorthingSettings, + apply_north_table, + estimate_north_table, + north_farm, + veer_normalised, + yaw_usable, +) + +TIMEBASE_S = 600 +RATED_POWER = 2300.0 + + +def _index(days: float = 400.0, start: str = "2017-01-01") -> pd.DatetimeIndex: + """A 10-minute UTC index spanning ``days``.""" + periods = round(days * 24 * 3600 / TIMEBASE_S) + return pd.date_range(start=start, periods=periods, freq=f"{TIMEBASE_S}s", tz="UTC") + + +def _true_direction(index: pd.DatetimeIndex, *, seed: int = 0) -> np.ndarray: + """A plausible site wind direction: a slow random walk covering the whole compass.""" + rng = np.random.default_rng(seed) + steps = rng.normal(0.0, 2.0, size=len(index)) + return np.cumsum(steps) % 360.0 + + +def _stepped_offset(index: pd.DatetimeIndex, steps: list[tuple[str, float]]) -> np.ndarray: + """Step-applied offset (deg) over ``index`` from ``(timestamp, offset)`` pairs.""" + out = np.full(len(index), steps[0][1], dtype=float) + for when, offset in steps[1:]: + out[index >= pd.Timestamp(when, tz="UTC")] = offset + return out + + +def _reported( + index: pd.DatetimeIndex, + *, + steps: list[tuple[str, float]], + noise_deg: float = 6.0, + seed: int = 0, +) -> tuple[np.ndarray, np.ndarray]: + """Return ``(reported_direction, reference_direction)`` for a turbine miscalibrated by ``steps``. + + The reference is the true site direction; the turbine reports it minus its north offset, + plus per-record yaw scatter. Recovering ``steps`` from the pair is the estimator's job. + """ + reference = _true_direction(index, seed=seed) + rng = np.random.default_rng(seed + 1) + scatter = rng.normal(0.0, noise_deg, size=len(index)) + reported = (reference + scatter - _stepped_offset(index, steps)) % 360.0 + return reported, reference + + +def _all_usable(index: pd.DatetimeIndex) -> np.ndarray: + return np.ones(len(index), dtype=bool) + + +class TestEstimateNorthTable: + def test_recovers_a_single_known_step(self) -> None: + index = _index() + steps = [("2017-01-01", 12.0), ("2017-08-01", 47.0)] + reported, reference = _reported(index, steps=steps) + + table = estimate_north_table(index, reported, reference_deg=reference, usable=_all_usable(index)) + + assert len(table) == 2 + assert table["timestamp"].iloc[0] == index.min() + # the changepoint is found within a day of truth + assert abs(table["timestamp"].iloc[1] - pd.Timestamp("2017-08-01", tz="UTC")) <= pd.Timedelta(days=1) + assert table["north_offset"].iloc[0] == pytest.approx(12.0, abs=1.0) + assert table["north_offset"].iloc[1] == pytest.approx(47.0, abs=1.0) + + def test_no_step_returns_a_single_row(self) -> None: + index = _index() + reported, reference = _reported(index, steps=[("2017-01-01", 25.0)]) + + table = estimate_north_table(index, reported, reference_deg=reference, usable=_all_usable(index)) + + assert len(table) == 1 + assert table["north_offset"].iloc[0] == pytest.approx(25.0, abs=1.0) + + def test_recovers_several_steps(self) -> None: + index = _index(days=700) + steps = [("2017-01-01", 0.0), ("2017-06-01", 35.0), ("2017-11-15", -20.0), ("2018-05-01", 60.0)] + reported, reference = _reported(index, steps=steps) + + table = estimate_north_table(index, reported, reference_deg=reference, usable=_all_usable(index)) + + assert len(table) == 4 + for row, (when, offset) in zip(table.itertuples(), steps, strict=True): + assert abs(row.timestamp - pd.Timestamp(when, tz="UTC")) <= pd.Timedelta(days=2) + assert circ_diff(row.north_offset, offset) == pytest.approx(0.0, abs=1.5) + + def test_handles_wraparound_in_raw_and_corrected_signals(self) -> None: + # offsets chosen so both the reported signal and the corrected one cross 0/360 often + index = _index() + steps = [("2017-01-01", 343.0), ("2017-07-01", 290.0)] + reported, reference = _reported(index, steps=steps) + + table = estimate_north_table(index, reported, reference_deg=reference, usable=_all_usable(index)) + corrected = apply_north_table(index, reported, north_table=table) + + assert len(table) == 2 + assert circ_diff(corrected, reference).mean() == pytest.approx(0.0, abs=1.0) + assert corrected.min() >= 0.0 + assert corrected.max() < 360.0 + + def test_ignores_unusable_rows(self) -> None: + index = _index() + steps = [("2017-01-01", 10.0), ("2017-09-01", 40.0)] + reported, reference = _reported(index, steps=steps) + # corrupt half the record, then mark it unusable: the estimate must be unmoved + usable = _all_usable(index) + rng = np.random.default_rng(7) + corrupt = rng.random(len(index)) < 0.5 + reported = np.where(corrupt, rng.uniform(0, 360, len(index)), reported) + usable &= ~corrupt + + table = estimate_north_table(index, reported, reference_deg=reference, usable=usable) + + assert len(table) == 2 + assert table["north_offset"].iloc[0] == pytest.approx(10.0, abs=1.5) + assert table["north_offset"].iloc[1] == pytest.approx(40.0, abs=1.5) + + def test_min_segment_prevents_micro_splits(self) -> None: + index = _index() + # two steps two days apart -- inside the 7-day min_segment, so they cannot both be kept + steps = [("2017-01-01", 0.0), ("2017-06-01", 30.0), ("2017-06-03", 60.0)] + reported, reference = _reported(index, steps=steps) + + table = estimate_north_table(index, reported, reference_deg=reference, usable=_all_usable(index)) + + gaps = table["timestamp"].diff().dropna() + assert (gaps >= pd.Timedelta(days=7)).all() + + +class TestNoiseFloor: + """`min_step_deg` is the smallest step the estimator will report.""" + + @staticmethod + def _n_changepoints(step_deg: float, *, settings: NorthingSettings = DEFAULT_NORTHING) -> int: + index = _index() + reported, reference = _reported(index, steps=[("2017-01-01", 0.0), ("2017-07-01", step_deg)]) + table = estimate_north_table( + index, reported, reference_deg=reference, usable=_all_usable(index), settings=settings + ) + return len(table) - 1 + + def test_step_well_above_min_step_is_found(self) -> None: + assert self._n_changepoints(6.0) == 1 + + def test_step_well_below_min_step_is_not_reported(self) -> None: + assert self._n_changepoints(0.5) == 0 + + def test_the_threshold_is_what_decides_not_the_data_volume(self) -> None: + """The same 2 degree step is invisible by default and found with the threshold lowered.""" + step = 2.0 + assert self._n_changepoints(step) == 0 + assert self._n_changepoints(step, settings=replace(DEFAULT_NORTHING, min_step_deg=1.0)) == 1 + + +class TestDegenerateInput: + def test_all_unusable_returns_a_zero_offset_row(self) -> None: + index = _index(days=30) + reported, reference = _reported(index, steps=[("2017-01-01", 30.0)]) + + table = estimate_north_table(index, reported, reference_deg=reference, usable=np.zeros(len(index), dtype=bool)) + + assert len(table) == 1 + assert table["north_offset"].iloc[0] == 0.0 + assert table["timestamp"].iloc[0] == index.min() + + def test_all_nan_direction_returns_a_zero_offset_row(self) -> None: + index = _index(days=30) + _, reference = _reported(index, steps=[("2017-01-01", 30.0)]) + + table = estimate_north_table( + index, + np.full(len(index), np.nan), + reference_deg=reference, + usable=_all_usable(index), + ) + + assert len(table) == 1 + assert table["north_offset"].iloc[0] == 0.0 + + def test_empty_index_raises(self) -> None: + empty = pd.DatetimeIndex([], tz="UTC") + with pytest.raises(ValueError, match="empty"): + estimate_north_table(empty, np.array([]), reference_deg=np.array([]), usable=np.array([], dtype=bool)) + + def test_mismatched_lengths_raise(self) -> None: + index = _index(days=10) + with pytest.raises(ValueError, match="same length"): + estimate_north_table( + index, + np.zeros(len(index)), + reference_deg=np.zeros(len(index) - 1), + usable=_all_usable(index), + ) + + +class TestApplyNorthTable: + def test_applies_steps_and_wraps(self) -> None: + index = _index(days=30) + table = pd.DataFrame( + { + "timestamp": [index.min(), pd.Timestamp("2017-01-15", tz="UTC")], + "north_offset": [30.0, 350.0], + } + ) + direction = np.full(len(index), 20.0) + + corrected = apply_north_table(index, direction, north_table=table) + + before = index < pd.Timestamp("2017-01-15", tz="UTC") + assert np.allclose(corrected[before], 50.0) + assert np.allclose(corrected[~before], 10.0) # (20 + 350) % 360 + + def test_one_table_serves_several_fields(self) -> None: + index = _index(days=30) + table = pd.DataFrame({"timestamp": [index.min()], "north_offset": [45.0]}) + yaw = np.full(len(index), 100.0) + measured_wd = np.full(len(index), 110.0) + + assert np.allclose(apply_north_table(index, yaw, north_table=table), 145.0) + assert np.allclose(apply_north_table(index, measured_wd, north_table=table), 155.0) + + def test_preserves_nan(self) -> None: + index = _index(days=10) + table = pd.DataFrame({"timestamp": [index.min()], "north_offset": [45.0]}) + direction = np.full(len(index), 100.0) + direction[:5] = np.nan + + corrected = apply_north_table(index, direction, north_table=table) + + assert np.isnan(corrected[:5]).all() + assert np.allclose(corrected[5:], 145.0) + + def test_round_trip_removes_the_injected_offset(self) -> None: + index = _index() + steps = [("2017-01-01", 15.0), ("2017-10-01", -25.0)] + reported, reference = _reported(index, steps=steps, noise_deg=4.0) + + table = estimate_north_table(index, reported, reference_deg=reference, usable=_all_usable(index)) + corrected = apply_north_table(index, reported, north_table=table) + + assert np.abs(circ_diff(corrected, reference)).mean() < np.abs(circ_diff(reported, reference)).mean() + assert circ_diff(corrected, reference).mean() == pytest.approx(0.0, abs=0.5) + + +class TestYawUsable: + def test_requires_power_reference_and_uptime(self) -> None: + n = 6 + power = np.array([1000.0, 1000.0, 1000.0, 10.0, 1000.0, 1000.0]) + downtime = np.array([0.0, 0.0, 0.0, 0.0, 300.0, 0.0]) + reference = np.array([10.0, 10.0, np.nan, 10.0, 10.0, 10.0]) + power[1] = np.nan + + usable = yaw_usable( + power=power, + downtime_s=downtime, + reference_deg=reference, + rated_power=RATED_POWER, + timebase_s=TIMEBASE_S, + ) + + assert usable.tolist() == [True, False, False, False, False, True] + assert usable.shape == (n,) + + +class TestNorthFarm: + @staticmethod + def _farm( + index: pd.DatetimeIndex, offsets: dict[str, list[tuple[str, float]]], *, seed: int = 0 + ) -> tuple[dict[str, np.ndarray], np.ndarray]: + """Reported directions for each device plus the shared true site direction.""" + reference = _true_direction(index, seed=seed) + reported = {} + for i, (name, steps) in enumerate(offsets.items()): + rng = np.random.default_rng(100 + i) + scatter = rng.normal(0.0, 6.0, size=len(index)) + reported[name] = (reference + scatter - _stepped_offset(index, steps)) % 360.0 + return reported, reference + + def test_two_pass_recovers_per_device_steps(self) -> None: + index = _index() + offsets = { + "T01": [("2017-01-01", 0.0)], + "T02": [("2017-01-01", 8.0)], + "T03": [("2017-01-01", -5.0), ("2017-08-01", 35.0)], + "T04": [("2017-01-01", 3.0)], + } + reported, reference = self._farm(index, offsets) + + tables = north_farm( + index, + direction_deg=reported, + usable={name: _all_usable(index) for name in reported}, + reanalysis_deg=reference, + ) + + assert set(tables) == set(offsets) + assert len(tables["T03"]) == 2 + for name, steps in offsets.items(): + corrected = apply_north_table(index, reported[name], north_table=tables[name]) + assert circ_diff(corrected, reference).mean() == pytest.approx(0.0, abs=2.0), name + assert len(tables[name]) == len(steps), name + + def test_recovers_a_farm_that_is_uniformly_180_degrees_wrong(self) -> None: + """The reanalysis pass is load-bearing: a common-mode offset is invisible to pass 2 alone. + + Every device agrees with every other, so a farm-relative method sees a perfectly + consistent farm and reports nothing wrong. + """ + index = _index() + offsets = {name: [("2017-01-01", 180.0)] for name in ("T01", "T02", "T03", "T04")} + reported, reference = self._farm(index, offsets) + + tables = north_farm( + index, + direction_deg=reported, + usable={name: _all_usable(index) for name in reported}, + reanalysis_deg=reference, + ) + + for name in offsets: + corrected = apply_north_table(index, reported[name], north_table=tables[name]) + assert circ_diff(corrected, reference).mean() == pytest.approx(0.0, abs=2.0), name + # and the recovered offset really is the 180 that was injected + assert circ_diff(tables[name]["north_offset"].iloc[0], 180.0) == pytest.approx(0.0, abs=2.0) + + def test_pass_two_beats_reanalysis_alone(self) -> None: + """The farm reference is less noisy than reanalysis, so two passes beat one.""" + index = _index() + offsets = {name: [("2017-01-01", 20.0)] for name in ("T01", "T02", "T03", "T04")} + reported, reference = self._farm(index, offsets) + # reanalysis is a degraded view of the true direction; the farm's own consensus is better + rng = np.random.default_rng(11) + reanalysis = (reference + rng.normal(0.0, 25.0, size=len(index))) % 360.0 + + one_pass = estimate_north_table(index, reported["T01"], reference_deg=reanalysis, usable=_all_usable(index)) + two_pass = north_farm( + index, + direction_deg=reported, + usable={name: _all_usable(index) for name in reported}, + reanalysis_deg=reanalysis, + )["T01"] + + truth = 20.0 + assert abs(circ_diff(two_pass["north_offset"].iloc[0], truth)) <= abs( + circ_diff(one_pass["north_offset"].iloc[0], truth) + ) + + def test_raises_when_too_few_devices_for_a_farm_reference(self) -> None: + index = _index(days=30) + offsets = {"T01": [("2017-01-01", 0.0)], "T02": [("2017-01-01", 5.0)]} + reported, reference = self._farm(index, offsets) + + with pytest.raises(ValueError, match="min_devices_for_farm_reference"): + north_farm( + index, + direction_deg=reported, + usable={name: _all_usable(index) for name in reported}, + reanalysis_deg=reference, + min_devices_for_farm_reference=3, + ) + + +class TestSettings: + def test_the_default_needs_no_argument(self) -> None: + index = _index(days=30) + reported, reference = _reported(index, steps=[("2017-01-01", 10.0)]) + table = estimate_north_table(index, reported, reference_deg=reference, usable=_all_usable(index)) + assert table["north_offset"].iloc[0] == pytest.approx(10.0, abs=2.0) + + def test_a_short_record_still_gets_a_floor_of_changepoints(self) -> None: + """`ceil(rate * years)` alone would allow one over a month; the floor leaves room for more.""" + index = _index(days=31) + steps = [("2017-01-01", 0.0), ("2017-01-10", 40.0), ("2017-01-20", 80.0)] + reported, reference = _reported(index, steps=steps) + table = estimate_north_table(index, reported, reference_deg=reference, usable=_all_usable(index)) + assert len(table) == 3 + + def test_changepoint_budget_scales_with_record_length(self) -> None: + """`changepoints_per_year` is a rate, so a longer record gets a larger budget.""" + settings = NorthingSettings(changepoints_per_year=1.0, min_step_deg=3.0, refine=False, min_changepoints=0) + steps = [("2017-01-01", 0.0), ("2017-04-01", 30.0), ("2017-07-01", 60.0), ("2017-10-01", 90.0)] + + short_index = _index(days=200) + short_reported, short_reference = _reported(short_index, steps=steps[:3]) + short = estimate_north_table( + short_index, + short_reported, + reference_deg=short_reference, + usable=_all_usable(short_index), + settings=settings, + ) + + long_index = _index(days=1400) + long_reported, long_reference = _reported(long_index, steps=steps) + long = estimate_north_table( + long_index, long_reported, reference_deg=long_reference, usable=_all_usable(long_index), settings=settings + ) + + assert len(short) - 1 <= 1 # ceil(1 * 0.55 years) + assert len(long) - 1 == 3 # ceil(1 * 3.8 years) allows all three + + +class TestVeerNormalisation: + """Across a site the wind direction differs turbine to turbine, and that difference shifts + with the bulk direction. A changing direction *mix* must not look like a step.""" + + def test_removes_a_direction_dependent_level(self) -> None: + index = _index() + reference = _true_direction(index) + # a turbine reading 8 deg high in the northern half of the compass and 8 low in the south + veer = np.where((reference < 180.0), 8.0, -8.0) + residual = veer + np.random.default_rng(3).normal(0.0, 2.0, len(index)) + + out = veer_normalised(residual, reference_deg=reference, sector_deg=30.0) + + north = out[reference < 180.0] + south = out[reference >= 180.0] + assert np.nanmedian(north) == pytest.approx(0.0, abs=0.5) + assert np.nanmedian(south) == pytest.approx(0.0, abs=0.5) + + def test_a_uniform_offset_survives_because_it_shifts_every_sector_alike(self) -> None: + index = _index() + reference = _true_direction(index) + residual = np.full(len(index), 20.0) + shifted = veer_normalised(residual + 5.0, reference_deg=reference, sector_deg=30.0) + base = veer_normalised(residual, reference_deg=reference, sector_deg=30.0) + # the constant is absorbed, so what remains is identical -- the step is carried by the + # segment offsets, which come from the raw residual + assert np.nanmax(np.abs(shifted - base)) == pytest.approx(0.0, abs=1e-9) + + def test_a_shifting_direction_mix_no_longer_reads_as_a_step(self) -> None: + """No offset changes; only which directions the wind comes from. Nothing must be found.""" + index = _index(days=400) + rng = np.random.default_rng(5) + half = len(index) // 2 + # first half blows from the north, second half from the south + reference = np.concatenate( + [rng.normal(0.0, 25.0, half) % 360.0, (rng.normal(180.0, 25.0, len(index) - half)) % 360.0] + ) + veer = np.where(reference < 180.0, 8.0, -8.0) + reported = (reference + veer + rng.normal(0.0, 5.0, len(index))) % 360.0 + + table = estimate_north_table(index, reported, reference_deg=reference, usable=_all_usable(index)) + + assert len(table) == 1, f"veer mistaken for a step: {table}" + + +class TestTransientPruning: + """Site veer wanders away and back; a recalibration does not.""" + + @staticmethod + def _n_changepoints(steps: list[tuple[str, float]]) -> int: + index = _index(days=700) + reported, reference = _reported(index, steps=steps) + table = estimate_north_table(index, reported, reference_deg=reference, usable=_all_usable(index)) + return len(table) - 1 + + def test_a_small_self_cancelling_excursion_is_ironed_out(self) -> None: + # away by 6 deg for two months and back again: the record ends where it started + assert self._n_changepoints([("2017-01-01", 0.0), ("2017-06-01", 6.0), ("2017-08-01", 0.0)]) == 0 + + def test_a_small_persistent_step_is_kept(self) -> None: + # the same 6 deg, but it stays -- that is a recalibration + assert self._n_changepoints([("2017-01-01", 0.0), ("2017-06-01", 6.0)]) == 1 + + def test_a_large_excursion_is_kept_even_though_it_cancels(self) -> None: + """A real recalibration is sometimes reversed later; its size is the evidence it happened.""" + assert self._n_changepoints([("2017-01-01", 0.0), ("2017-06-01", 90.0), ("2017-11-01", 0.0)]) == 2 + + def test_a_long_oscillation_of_small_steps_is_removed_entirely(self) -> None: + # six steps of 5 deg that end back where they started -- veer, not six recalibrations + steps: list[tuple[str, float]] = [("2017-01-01", 0.0)] + dates = ("2017-04-01", "2017-07-01", "2017-10-01", "2018-01-01", "2018-04-01", "2018-07-01") + steps.extend((when, 5.0 if i % 2 == 0 else 0.0) for i, when in enumerate(dates)) + assert steps[-1][1] == 0.0, "the oscillation must return to its starting level" + assert self._n_changepoints(steps) == 0 + + def test_an_oscillation_biased_enough_to_shift_the_level_is_not_fully_ironed_out(self) -> None: + """The pruning is a threshold rule, not an oracle: an oscillation whose halves sit at + genuinely different levels keeps the changepoints that carry that difference.""" + steps: list[tuple[str, float]] = [("2017-01-01", 0.0)] + dates = ("2017-04-01", "2017-07-01", "2017-10-01", "2018-01-01", "2018-04-01", "2018-07-01") + steps.extend((when, 7.0 if i % 2 == 0 else 0.0) for i, when in enumerate(dates)) + kept = self._n_changepoints(steps) + assert 0 < kept < len(steps) - 1 diff --git a/uv.lock b/uv.lock index 4fc76270..3be049a4 100644 --- a/uv.lock +++ b/uv.lock @@ -344,7 +344,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -415,7 +415,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -675,7 +675,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -880,17 +880,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version < '3.11'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "jedi", marker = "python_full_version < '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, - { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "stack-data", marker = "python_full_version < '3.11'" }, - { name = "traitlets", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } wheels = [ @@ -906,18 +906,18 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version >= '3.11'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, - { name = "jedi", marker = "python_full_version >= '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, - { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, - { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "stack-data", marker = "python_full_version >= '3.11'" }, - { name = "traitlets", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e2/23/3a27530575643c8bb7bfc757a28e2e7ef80092afbf59a2bc5716320b6602/ipython-9.14.1.tar.gz", hash = "sha256:f913bf74df06d458e46ced84ca506c23797590d594b236fe60b14df213291e7b", size = 4433457, upload-time = "2026-06-05T08:12:34.921Z" } wheels = [ @@ -929,7 +929,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -2657,7 +2657,6 @@ dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "pyyaml" }, - { name = "ruptures" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "seaborn" }, @@ -2729,7 +2728,6 @@ requires-dist = [ { name = "requests", marker = "extra == 'examples'" }, { name = "requests-cache", marker = "extra == 'era5'" }, { name = "retry-requests", marker = "extra == 'era5'" }, - { name = "ruptures" }, { name = "scikit-learn", marker = "extra == 'ml'" }, { name = "scipy" }, { name = "seaborn" }, @@ -3011,40 +3009,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, ] -[[package]] -name = "ruptures" -version = "1.1.10" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/64/32/3bcf5a62479b4d83187f872fc68b918b59285f560bee4f37c5d49b1d957a/ruptures-1.1.10.tar.gz", hash = "sha256:76b998f10709045e91a5f44173dc574bf98a106fd3bf22ec3c63b298a02df031", size = 320689, upload-time = "2025-09-10T09:48:02.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/6b/fbd0f47c95c2e55bfe21447fb5f3878692f1a3686e2f65e70bc7dbbea15b/ruptures-1.1.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d2dae4173db1ffab6cf1eead44825b360e354b3c37f7316b7a827dea6a34b577", size = 497431, upload-time = "2025-09-10T09:47:30.729Z" }, - { url = "https://files.pythonhosted.org/packages/e1/6e/3fbe3d105bc9100535682dcae147a6f93bde7ebc29af7795236204b7952e/ruptures-1.1.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:20ea4ce1e7503bef60aa879196343acea660905f85692c9612d1a1f787e21da6", size = 495403, upload-time = "2025-09-10T09:47:32.478Z" }, - { url = "https://files.pythonhosted.org/packages/f0/61/e0015e0a95a310edfcf1dc3413ccb4afad1905e33511b78768b32647e14f/ruptures-1.1.10-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8810f3c9f71262fa5d4866ea0a8f2d0ea4e15a9d28d868c97043d260c8c87bc8", size = 1282302, upload-time = "2025-09-10T09:47:33.817Z" }, - { url = "https://files.pythonhosted.org/packages/48/8f/36bf50751f11a2b2e81b24b502fbc386c7ae2033a3faf3f7b4502dbdd7fd/ruptures-1.1.10-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1a62f024d3714facca48e8859ba2602367af0c7ca1680cffded1cbff7cf264f", size = 1289697, upload-time = "2025-09-10T09:47:35.229Z" }, - { url = "https://files.pythonhosted.org/packages/7e/d3/eb3fb911c23019ac6fdce873a959c63c344780a391ca55355c6fdb088912/ruptures-1.1.10-cp310-cp310-win_amd64.whl", hash = "sha256:171fe4f61044a0520e1cd4e429f69242570939700aa4990b3401bf9a8d61aebe", size = 476077, upload-time = "2025-09-10T09:47:36.417Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ea/b561e98a69e412c29a808cdb25acff73005b26d0313632b7ec29a5846f8c/ruptures-1.1.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4038c09d8148bde4c3689adc3570dd3728163326a0b7baa82474641b8276f53e", size = 496985, upload-time = "2025-09-10T09:47:37.754Z" }, - { url = "https://files.pythonhosted.org/packages/c6/7a/0026c5a85c11d6eb0202e828b5fa8dc829615f6bb61155463827ba1d2e5d/ruptures-1.1.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef2f26424b267a3da46b05b6d78322249ab1325754c61cafd9580d4eef0011ea", size = 494748, upload-time = "2025-09-10T09:47:38.798Z" }, - { url = "https://files.pythonhosted.org/packages/85/81/06be598b7dedbcd63683296ebae683dbdab62bb0134fc8a6fd05a4b74e03/ruptures-1.1.10-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:728ec63779ebb94341caaca70c57d01eb6a22cb98d7e3079935a73a43febcb30", size = 1335652, upload-time = "2025-09-10T09:47:40.118Z" }, - { url = "https://files.pythonhosted.org/packages/98/c0/0d211972132f2161c594a67552fd0eb30697750c13e7351ab9dc0bb1f2a5/ruptures-1.1.10-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14860e1b1bbb439837320147dcd71ad31fc76a35988de94b5aa90bb0d22b1022", size = 1341581, upload-time = "2025-09-10T09:47:41.208Z" }, - { url = "https://files.pythonhosted.org/packages/d8/a9/14aa9d2413ad7c29ab9c983cf5f6ce65202c1a63af56a56d1ab075bcf91d/ruptures-1.1.10-cp311-cp311-win_amd64.whl", hash = "sha256:733adb020910dbcfc6fdbca7295db28295177a927a70002c7080f2a4bfbf1840", size = 475907, upload-time = "2025-09-10T09:47:42.727Z" }, - { url = "https://files.pythonhosted.org/packages/c6/18/2b93d310a3a96393d48ee06363db860c576dcff3f925c8873a0ca7be219f/ruptures-1.1.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a7f614c63f2cecd76d7b5f9f79ff34712234d274a41867428ace4a3a45757c8", size = 499197, upload-time = "2025-09-10T09:47:43.707Z" }, - { url = "https://files.pythonhosted.org/packages/67/28/a25c5eecf1b9df53d27482c953c573515ab0056e64b0e9173312344fc169/ruptures-1.1.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14e3aa451220a05c6a7782a3b31fdd4fc578d39b77404dc94649bcae5eebd1db", size = 496449, upload-time = "2025-09-10T09:47:45.206Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/e54eba1861ab1be8d63fe019ce57632422ae8d0e8979b897d3f2fb4ca33d/ruptures-1.1.10-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01fb8847a58cbb158103c1810022ea29e3fa3291d6531631bc6134f636ea2fc1", size = 1327050, upload-time = "2025-09-10T09:47:46.228Z" }, - { url = "https://files.pythonhosted.org/packages/78/57/5d00e8500b4d906809b86a23390c035e40460f2ff90acd134a6198320428/ruptures-1.1.10-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0930cecc7ec1c9af0b1a7da6fb769d298691e077c6cba8daad6ebaef49ed8e80", size = 1346678, upload-time = "2025-09-10T09:47:47.634Z" }, - { url = "https://files.pythonhosted.org/packages/a1/0a/df3f6843d4715e571b1bf4c075802c1cd002d1e462a1fc54faeda79a3b21/ruptures-1.1.10-cp312-cp312-win_amd64.whl", hash = "sha256:4be700aa3fee9057667062343a2fa728e25765f457ea54dac116fd2f4b7f49c9", size = 477643, upload-time = "2025-09-10T09:47:48.799Z" }, - { url = "https://files.pythonhosted.org/packages/29/40/087c4deede27066eb9f075c3768749366030bb24fd2c490fb1affe0b8a3f/ruptures-1.1.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:29038a863f52c026d950066a62d23719304273e2cef41159259422a055f04fc0", size = 497249, upload-time = "2025-09-10T09:47:50.204Z" }, - { url = "https://files.pythonhosted.org/packages/c6/84/f5fe5ded842d1c57ecd53db71f1e9c099ce14d7d2c2cc997f0351ef6f0cb/ruptures-1.1.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bfaf4dbf51ae74a4f8893ba36be98912cd4bd7f2a94a79a5ff0d2edca65c21f5", size = 494490, upload-time = "2025-09-10T09:47:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/0f/26/29499f16b62220ce3b0bb75a8f8de921814e55c223e3263df7f2733eb5a7/ruptures-1.1.10-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4d0049051530e73babc2f44e75e6ba520bb90fe5bfa60d59c4d054180893e", size = 1311189, upload-time = "2025-09-10T09:47:52.701Z" }, - { url = "https://files.pythonhosted.org/packages/f3/dc/f0a268190b233c4d4b9fa50cd7078bbc536bf319d724218087b2cb4a1138/ruptures-1.1.10-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fada68f6617e26138151a1ce6f73932e84ae01119e48be005773a40363454e51", size = 1330363, upload-time = "2025-09-10T09:47:53.999Z" }, - { url = "https://files.pythonhosted.org/packages/bd/29/1fe6a1b1811f64bdd35b61300e9d57f68d630d271c92423553a30072d5c5/ruptures-1.1.10-cp313-cp313-win_amd64.whl", hash = "sha256:39f3ece91327440fbebca84155b39435970ccc0f52bd3e33878aaff157d04e01", size = 477215, upload-time = "2025-09-10T09:47:55.027Z" }, -] - [[package]] name = "scikit-learn" version = "1.7.2" @@ -3053,10 +3017,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "joblib", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, + { name = "joblib" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } wheels = [ @@ -3096,11 +3060,11 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "joblib", marker = "python_full_version >= '3.11'" }, - { name = "narwhals", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" } }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } wheels = [ @@ -3132,7 +3096,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -3192,7 +3156,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ From f8772b37b553093cc52082b6ee5985d7ba8ef162 Mon Sep 17 00:00:00 2001 From: aclerc Date: Wed, 2 Sep 2026 16:33:09 +0100 Subject: [PATCH 02/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- .gitattributes | 1 + .../specs/2026-09-02-r1-northing-design.md | 63 ++++ docs/v1/issues_campaigns.md | 7 + src/wind_up/northing.py | 62 +++- src/wind_up/northing_plots.py | 345 ++++++++++++++++++ .../hot/northing/northing_inputs.parquet | 3 + tests/wind_up/test_northing.py | 34 ++ tests/wind_up/test_northing_real_data.py | 170 +++++++++ 8 files changed, 678 insertions(+), 7 deletions(-) create mode 100644 src/wind_up/northing_plots.py create mode 100644 tests/test_data/hot/northing/northing_inputs.parquet create mode 100644 tests/wind_up/test_northing_real_data.py diff --git a/.gitattributes b/.gitattributes index 7d4f42f7..eab3846a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,4 @@ tests/test_data/smarteole/* filter=lfs diff=lfs merge=lfs -text tests/test_data/wedowind/Turbine_Upgrade_Dataset.zip filter=lfs diff=lfs merge=lfs -text tests/test_data/hot/scada/* filter=lfs diff=lfs merge=lfs -text tests/test_data/hot/reanalysis_data/ERA5T_57.50N_-3.25E_100m_1hr_20241231.parquet filter=lfs diff=lfs merge=lfs -text +tests/test_data/hot/northing/*.parquet filter=lfs diff=lfs merge=lfs -text diff --git a/docs/superpowers/specs/2026-09-02-r1-northing-design.md b/docs/superpowers/specs/2026-09-02-r1-northing-design.md index 8b4e8467..583699cf 100644 --- a/docs/superpowers/specs/2026-09-02-r1-northing-design.md +++ b/docs/superpowers/specs/2026-09-02-r1-northing-design.md @@ -222,6 +222,69 @@ On the fixture the effect is starker still: the clean arms now discover **0** ch faulted arms exactly **1** — the injected fault and nothing else, where the first implementation found 7–11 spurious ones per run. +## Evidence: what the record's edges do, and what an outage does + +Two further artefacts, both found by comparing against v0's published table on Hill of Towie +rather than by reasoning. + +### The edge artefact (fixed) + +The estimator reported a +3.5° 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; every window not ending there is clean. + +| window | T13 changepoints | +|---|---| +| 2016-01-01 → 2018-01-01 | none | +| 2017-01-01 → **2019-01-01** | **2018-12-20, +3.36°** | +| 2017-01-01 → **2019-01-03** | none | +| 2017-01-01 → 2019-08-17 | none | + +A step in the data does not care where the record happens to stop, so this is the estimator, not +the turbine. The cause is the persistence test: with twelve days after it, the "after" level is a +veer-dominated estimate that came out 3.36° from the "before" level, scraping over the 3.0° +threshold. + +**The fix is to scale the required step with the record supporting it.** A segment's level is +limited by site veer rather than by sampling noise, and veer averages out no faster than +`1/sqrt(span)`, so: + +``` +required = clip(min_step_deg * sqrt(confident_segment / span), min_step_deg, max_transient_step_deg) +``` + +with `span` the shorter side of the changepoint and `confident_segment` 90 days — roughly the +record needed to average over veer's monthly wander. + +A flat "near an edge, demand more than 10°" rule was tried first and **rejected because it broke a +real detection**: T16's genuine +9.0° step on 2017-06-18 has only 30 days before it and 52 after. +The scaled rule requires 5.2° there and keeps it, requires 8.2° at T13's twelve days and drops it. +The cap at `max_transient_step_deg` keeps a large late jump findable, which a unit test pins. + +### The outage artefact (documented, not fixed) + +Over 2016–2020 nearly every turbine gains changepoint pairs at **2019-11-11/19** and +**2020-06-12/19** — steps of ~±12° and ~±16°, farm-wide, synchronous, self-cancelling within +about eight days. They survive the excursion filter because their size is above +`max_transient_step_deg`. + +Those weeks are farm outages. Two mechanisms, and only the first is fixable at the seam: + +1. **Silent reference substitution.** v0's `add_wf_yawdir` fills a missing farm direction with + reanalysis, which sits degrees away from the farm consensus. In the excursion weeks 35% and + 51% of rows are that fallback, and the turbine count falls to a median of **2** against the + ≥3 rule. Rows where the reference silently changed identity must not be used; excluding them + removes the August 2020 pair. **The v1 path is already correct here** — `_farm_direction` + returns NaN below `min_devices_for_farm_reference` and `yaw_usable` requires a finite + reference, so only the v0 adapter inherits the fallback. + +2. **Changing reference composition.** Excluding the fallback rows does *not* remove the November + 2019 or June 2020 pairs, because the fallback never triggers for them: three turbines still + report, just not the usual three. Turbines have different veer signatures, so a farm median + over a different subset is a different quantity. This is the same root cause as veer, one + level up, and it is **not fixed**: a strict-`xfail` test records it so it announces itself when + it is. + ## Prior art **HOGER** (Homogenization Of GEneral Regressions), Engie + CENER, merged into FLASC as diff --git a/docs/v1/issues_campaigns.md b/docs/v1/issues_campaigns.md index 5e8d4c8a..1d61cff7 100644 --- a/docs/v1/issues_campaigns.md +++ b/docs/v1/issues_campaigns.md @@ -431,6 +431,13 @@ the step bites `v0` and `power_model` on the clean fixture, then the shared northing step restores invariance; C3/C5 drop their bespoke northing wiring in favour of this step. the developed solution can be a drop-in replacement for the existing src/wind_up_v0/optimize_northing.py. Same or better performance is proven and useful test cases are ported. It should run MUCH faster (the old solution is a hand-rolled optimizer) and not require exotic dependencies (drop `ruptures`) +`power_model`'s reference-direction feature is **on by default**, not opt-in. That means the +shared northing step has to reach the study path too (it currently runs only in +`CampaignRunner`, so the study drivers behind the frozen benchmarks have no northed column), +and `study_power_model_compare_baseline.json` is regenerated. +the northing tool **shows its working**: per-turbine plots of the time-averaged residual +against the reference with the fitted step function overlaid, before and after correction, so +a user can see what was changed and judge it. Time averaging is what smears out veer. --- diff --git a/src/wind_up/northing.py b/src/wind_up/northing.py index 1b65a332..d81ee076 100644 --- a/src/wind_up/northing.py +++ b/src/wind_up/northing.py @@ -59,6 +59,10 @@ # A step larger than this is a recalibration whatever else the record does, so it is never ironed # out as wander -- real ones do sometimes reverse later. _MAX_TRANSIENT_STEP_DEG = 10.0 +# The span either side of a changepoint at which ``min_step_deg`` applies unmodified. With less +# record than this the level is veer-limited rather than sample-limited, so a bigger step is +# needed to tell a recalibration from the wander. +_DEFAULT_CONFIDENT_SEGMENT = pd.Timedelta(days=90) @dataclass(frozen=True) @@ -84,7 +88,12 @@ class NorthingSettings: ``None`` searches the raw residual. :param max_transient_step_deg: the largest step that may be ironed out as wander. Above it a step is treated as a recalibration however the record behaves afterwards, since real ones - are sometimes reversed later. + are sometimes reversed later. Also the ceiling on the support-scaled threshold, so a big + enough step is credible however little record sits either side of it. + :param confident_segment: the span either side of a changepoint at which ``min_step_deg`` + applies as written; with less record than that the required step grows as + ``sqrt(confident_segment / span)``, since the level is veer-limited and veer averages out + no faster than that. """ changepoints_per_year: float = 12.0 @@ -95,6 +104,7 @@ class NorthingSettings: min_segment: pd.Timedelta = _DEFAULT_MIN_SEGMENT veer_sector_deg: float | None = _DEFAULT_VEER_SECTOR_DEG max_transient_step_deg: float = _MAX_TRANSIENT_STEP_DEG + confident_segment: pd.Timedelta = _DEFAULT_CONFIDENT_SEGMENT # Reanalysis is a modelled, drift-prone direction: a shift in it looks exactly like a shift in @@ -441,27 +451,62 @@ def _prune_transient_steps( return changepoints, offsets +def _required_step( + changepoints: list[pd.Timestamp], + *, + start: pd.Timestamp, + end: pd.Timestamp, + min_step_deg: float, + max_transient_step_deg: float, + confident_segment: pd.Timedelta, +) -> npt.NDArray[np.float64]: + """Return the step size each changepoint must reach, given the record supporting it. + + A segment's level is limited by site veer rather than by sampling noise, and veer averages out + no faster than ``1/sqrt(span)``. So with less than ``confident_segment`` either side the + required step grows accordingly, capped at ``max_transient_step_deg`` -- above which a step is + credible however little record sits around it. + """ + edges = [start, *changepoints, end] + spans = np.array([max((b - a) / confident_segment, 1e-9) for a, b in itertools.pairwise(edges)]) + support = np.minimum(spans[:-1], spans[1:]) + return np.clip(min_step_deg / np.sqrt(np.minimum(support, 1.0)), min_step_deg, max_transient_step_deg) + + def _prune_small_steps( changepoints: list[pd.Timestamp], offsets: list[float], *, start: pd.Timestamp, + end: pd.Timestamp, residual: npt.NDArray[np.float64], index: pd.DatetimeIndex, min_step_deg: float, + max_transient_step_deg: float, + confident_segment: pd.Timedelta, ) -> tuple[list[pd.Timestamp], list[float]]: - """Drop changepoints whose estimated step is below ``min_step_deg``, smallest first. + """Drop changepoints whose step is too small for the record supporting them. This is what makes ``min_step_deg`` mean what it says: a step smaller than it is never - reported, however much data supports it. Offsets are re-estimated after each merge, since - merging two segments changes the level of the result. + reported. Near the start or end of a record -- or squeezed between two other changepoints -- + more is required, because there is less data to tell a step from veer. Offsets are + re-estimated after each merge, since merging two segments changes the level of the result. """ while changepoints: steps = np.abs(circ_diff(np.array(offsets[1:]), np.array(offsets[:-1]))) - smallest = int(np.argmin(steps)) - if steps[smallest] >= min_step_deg: + required = _required_step( + changepoints, + start=start, + end=end, + min_step_deg=min_step_deg, + max_transient_step_deg=max_transient_step_deg, + confident_segment=confident_segment, + ) + shortfall = required - steps + weakest = int(np.argmax(shortfall)) + if shortfall[weakest] <= 0: break - changepoints = [c for i, c in enumerate(changepoints) if i != smallest] + changepoints = [c for i, c in enumerate(changepoints) if i != weakest] offsets = _segment_offsets(changepoints, start=start, residual=residual, index=index) return changepoints, offsets @@ -579,9 +624,12 @@ def detect(searched: npt.NDArray[np.float64]) -> list[pd.Timestamp]: changepoints, offsets, start=start, + end=end, residual=residual, index=index, min_step_deg=settings.min_step_deg, + max_transient_step_deg=settings.max_transient_step_deg, + confident_segment=settings.confident_segment, ) return _table([start, *changepoints], offsets) diff --git a/src/wind_up/northing_plots.py b/src/wind_up/northing_plots.py new file mode 100644 index 00000000..88f24e9c --- /dev/null +++ b/src/wind_up/northing_plots.py @@ -0,0 +1,345 @@ +"""Show what the northing estimator did, so a user can judge it rather than trust it. + +Two views per device, because a northing error and site veer look alike in either one alone: + +* **over time** -- the residual against the reference, time-averaged so veer is smeared out, + before and after correction, with the fitted step function and its changepoints drawn on. This + is the view that answers "is the corrected direction believable to a degree?". +* **against direction** -- the same residual binned by the reference direction. What is left + after correction is site veer: the wind direction genuinely differs across a site, and no + north offset can remove it. A tilt here is expected; a vertical shift is not. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +from wind_up.circular_math import circ_diff, circ_median +from wind_up.northing import NORTH_OFFSET_COL, TIMESTAMP_COL, apply_north_table + +if TYPE_CHECKING: + from pathlib import Path + + from matplotlib.figure import Figure + +# The accuracy a corrected direction is judged against, drawn as a band around zero. +BELIEVABLE_DEG = 1.0 +_DEFAULT_AVERAGE = pd.Timedelta(days=14) +_DEFAULT_SECTOR_DEG = 30.0 +_MIN_ROWS_PER_POINT = 20 + + +def _binned_median(values: pd.Series, *, by: pd.Series, bins: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Circular median of ``values`` in each bin of ``by``, and the bin centres.""" + which = np.digitize(by.to_numpy(dtype=float), bins) - 1 + centres = (bins[:-1] + bins[1:]) / 2 + out = np.full(len(centres), np.nan) + for b in range(len(centres)): + rows = values.to_numpy(dtype=float)[which == b] + rows = rows[np.isfinite(rows)] + if len(rows) >= _MIN_ROWS_PER_POINT: + out[b] = circ_median(rows, range_360=False) + return centres, out + + +def _time_averaged(residual: pd.Series, *, average: pd.Timedelta) -> pd.Series: + """Circular median of the residual in each ``average``-long bin, indexed by bin start.""" + grouped = residual.dropna().groupby(pd.Grouper(freq=average)) + return grouped.apply( + lambda x: circ_median(x.to_numpy(), range_360=False) if len(x) >= _MIN_ROWS_PER_POINT else np.nan + ) + + +def _step_series(index: pd.DatetimeIndex, north_table: pd.DataFrame) -> np.ndarray: + """Return the correction the table applies at each timestamp, as a signed angle.""" + return np.asarray(circ_diff(apply_north_table(index, np.zeros(len(index)), north_table=north_table), 0.0)) + + +def plot_northing( + index: pd.DatetimeIndex, + direction_deg: np.ndarray, + *, + reference_deg: np.ndarray, + usable: np.ndarray, + north_table: pd.DataFrame, + device: str, + reference_name: str = "reference", + average: pd.Timedelta = _DEFAULT_AVERAGE, + sector_deg: float = _DEFAULT_SECTOR_DEG, + out_dir: Path | None = None, +) -> Figure: + """Plot one device's northing: the residual over time, and against direction. + + :param index: timestamps of every array + :param direction_deg: the **raw** direction signal, before correction + :param reference_deg: the direction it was northed against + :param usable: the rows the estimate was allowed to use + :param north_table: the estimated table, as returned by + :func:`~wind_up.northing.estimate_north_table` + :param device: name used in the title and filename + :param reference_name: what the reference is, for the axis labels + :param average: time-averaging window; longer smears out more veer + :param sector_deg: direction sector width for the lower panel + :param out_dir: when given, the figure is saved as ``_northing.png`` + :return: the figure, so a caller can further adjust or close it + """ + index = pd.DatetimeIndex(index) + ok = np.asarray(usable, dtype=bool) + corrected = apply_north_table(index, np.asarray(direction_deg, dtype=float), north_table=north_table) + before = pd.Series(np.where(ok, circ_diff(direction_deg, reference_deg), np.nan), index=index) + after = pd.Series(np.where(ok, circ_diff(corrected, reference_deg), np.nan), index=index) + + fig, (top, bottom) = plt.subplots(2, 1, figsize=(13, 8), height_ratios=[3, 2]) + + smoothed_before = _time_averaged(before, average=average) + smoothed_after = _time_averaged(after, average=average) + top.plot( + before.dropna().index, + before.dropna().to_numpy(), + ".", + color="0.85", + markersize=1, + zorder=1, + label="every record (uncorrected)", + ) + top.plot( + smoothed_before.index, + smoothed_before.to_numpy(), + color="tab:red", + linewidth=1.5, + zorder=3, + label=f"uncorrected, {_describe(average)} median", + ) + top.plot( + smoothed_after.index, + smoothed_after.to_numpy(), + color="tab:blue", + linewidth=1.8, + zorder=4, + label=f"corrected, {_describe(average)} median", + ) + top.plot( + index, + -_step_series(index, north_table), + color="black", + linewidth=1.2, + linestyle="--", + zorder=5, + label="fitted north offset (negated)", + ) + top.axhspan(-BELIEVABLE_DEG, BELIEVABLE_DEG, color="tab:blue", alpha=0.12, zorder=0) + top.axhline(0.0, color="k", linewidth=0.8, zorder=2) + for changepoint in pd.DatetimeIndex(north_table[TIMESTAMP_COL])[1:]: + top.axvline(changepoint, color="tab:orange", linewidth=1.2, linestyle=":", zorder=6) + _annotate_steps(top, north_table) + + span = np.nanpercentile(np.abs(smoothed_before.to_numpy()), 99) if smoothed_before.notna().any() else 10.0 + top.set_ylim(-max(span * 1.4, 12.0), max(span * 1.4, 12.0)) + top.set_ylabel(f"yaw - {reference_name} [deg]") + top.set_title( + f"{device}: northing against {reference_name} " + f"({len(north_table) - 1} changepoint{'s' if len(north_table) != 2 else ''}); " # noqa: PLR2004 + f"shaded band is +/-{BELIEVABLE_DEG:.0f} deg" + ) + top.grid(alpha=0.3) + top.legend(ncol=2, fontsize="small", loc="upper left") + + bins = np.arange(0.0, 360.0 + sector_deg, sector_deg) + reference = pd.Series(np.where(ok, reference_deg, np.nan), index=index) + centres, before_by_dir = _binned_median(before, by=reference, bins=bins) + _, after_by_dir = _binned_median(after, by=reference, bins=bins) + bottom.plot(centres, before_by_dir, "o-", color="tab:red", label="uncorrected") + bottom.plot(centres, after_by_dir, "o-", color="tab:blue", label="corrected") + bottom.axhspan(-BELIEVABLE_DEG, BELIEVABLE_DEG, color="tab:blue", alpha=0.12) + bottom.axhline(0.0, color="k", linewidth=0.8) + bottom.set_xlim(0, 360) + bottom.set_xticks(np.arange(0, 361, 45)) + bottom.set_xlabel(f"{reference_name} [deg]") + bottom.set_ylabel("median residual [deg]") + bottom.set_title("residual by direction sector: what remains after correction is site veer") + bottom.grid(alpha=0.3) + bottom.legend(fontsize="small") + + fig.tight_layout() + if out_dir is not None: + out_dir.mkdir(parents=True, exist_ok=True) + fig.savefig(out_dir / f"{device}_northing.png", dpi=130) + return fig + + +def _describe(average: pd.Timedelta) -> str: + """Return a short human label for an averaging window.""" + days = average / pd.Timedelta(days=1) + return f"{days:.0f}-day" if days >= 1 else f"{average / pd.Timedelta(hours=1):.0f}-hour" + + +def _annotate_steps(ax: plt.Axes, north_table: pd.DataFrame) -> None: + """Label each changepoint with the size of the step it applies.""" + offsets = north_table[NORTH_OFFSET_COL].to_numpy(dtype=float) + times = pd.DatetimeIndex(north_table[TIMESTAMP_COL]) + for i in range(1, len(offsets)): + step = float(circ_diff(offsets[i], offsets[i - 1])) + ax.annotate( + f"{step:+.1f}°", + xy=(times[i], 0.0), + xytext=(4, 6), + textcoords="offset points", + color="tab:orange", + fontsize="small", + fontweight="bold", + ) + + +def circular_spread(values_deg: np.ndarray) -> float: + """Circular standard deviation (deg) of an angle sample: ``sqrt(-2 ln R)``, R the resultant.""" + finite = np.asarray(values_deg, dtype=float) + finite = finite[np.isfinite(finite)] + if len(finite) < 2: # noqa: PLR2004 - a spread needs two samples + return float("nan") + rad = np.deg2rad(finite) + resultant = np.hypot(np.mean(np.sin(rad)), np.mean(np.cos(rad))) + if resultant <= 0.0: + return float("inf") + return float(np.degrees(np.sqrt(max(-2.0 * np.log(min(resultant, 1.0)), 0.0)))) + + +def _circular_mean(values_deg: np.ndarray) -> float: + """Circular mean (deg, wrapped to +/-180) of an angle sample.""" + finite = np.asarray(values_deg, dtype=float) + finite = finite[np.isfinite(finite)] + if len(finite) == 0: + return float("nan") + rad = np.deg2rad(finite) + return float((np.degrees(np.arctan2(np.mean(np.sin(rad)), np.mean(np.cos(rad)))) + 180.0) % 360.0 - 180.0) + + +def _by_bin( + residual: np.ndarray, driver: np.ndarray, edges: np.ndarray +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Circular mean, circular spread and record count of ``residual`` in each bin of ``driver``.""" + which = np.digitize(driver, edges) - 1 + centres = (edges[:-1] + edges[1:]) / 2 + mean = np.full(len(centres), np.nan) + spread = np.full(len(centres), np.nan) + count = np.zeros(len(centres)) + for b in range(len(centres)): + rows = residual[which == b] + rows = rows[np.isfinite(rows)] + count[b] = len(rows) + if len(rows) >= _MIN_ROWS_PER_POINT: + mean[b] = _circular_mean(rows) + spread[b] = circular_spread(rows) + return centres, mean, spread, count + + +def plot_residual_conditions( + residual_deg: np.ndarray, + *, + reference_deg: np.ndarray, + wind_speed: np.ndarray, + power: np.ndarray, + rated_power: float, + title: str, + sector_deg: float = _DEFAULT_SECTOR_DEG, + out_dir: Path | None = None, + filename: str = "residual_conditions.png", +) -> Figure: + """Mean and spread of the northing residual against direction, wind speed and power. + + The question these answer is whether the residual should be **weighted**: if its spread + blows up at low power or low wind speed, those records tell you less about where north is + and should count for less. A flat spread says an unweighted estimate is fine. + + Pass the residual **after** northing, over the rows the estimate was allowed to use. + """ + fraction = np.asarray(power, dtype=float) / rated_power + panels = ( + ( + "wind direction [deg]", + np.asarray(reference_deg, dtype=float), + np.arange(0.0, 360.0 + sector_deg, sector_deg), + ), + ("wind speed [m/s]", np.asarray(wind_speed, dtype=float), np.arange(0.0, 26.0, 1.0)), + ("power [fraction of rated]", fraction, np.arange(0.0, 1.05, 0.05)), + ) + fig, axes = plt.subplots(1, 3, figsize=(16, 4.6)) + for ax, (label, driver, edges) in zip(axes, panels, strict=True): + centres, mean, spread, count = _by_bin(np.asarray(residual_deg, dtype=float), driver, edges) + ax.fill_between(centres, mean - spread, mean + spread, color="tab:blue", alpha=0.2, label="+/-1 circular SD") + ax.plot(centres, mean, "o-", color="tab:blue", markersize=4, label="circular mean") + ax.axhline(0.0, color="k", linewidth=0.8) + ax.axhspan(-BELIEVABLE_DEG, BELIEVABLE_DEG, color="tab:green", alpha=0.12) + ax.set_xlabel(label) + ax.set_ylabel("residual [deg]") + ax.grid(alpha=0.3) + counts = ax.twinx() + counts.bar(centres, count, width=(edges[1] - edges[0]) * 0.85, color="0.8", zorder=0, alpha=0.5) + counts.set_ylabel("records", color="0.5") + counts.tick_params(axis="y", colors="0.5") + counts.set_zorder(0) + ax.set_zorder(1) + ax.patch.set_visible(False) + axes[0].legend(fontsize="small", loc="upper left") + fig.suptitle(f"{title}: northing residual mean and spread by condition") + fig.tight_layout() + if out_dir is not None: + out_dir.mkdir(parents=True, exist_ok=True) + fig.savefig(out_dir / filename, dpi=130) + return fig + + +def plot_northing_farm( + index: pd.DatetimeIndex, + *, + direction_deg: dict[str, np.ndarray], + reference_deg: np.ndarray, + usable: dict[str, np.ndarray], + north_tables: dict[str, pd.DataFrame], + average: pd.Timedelta = _DEFAULT_AVERAGE, + reference_name: str = "farm direction", + out_dir: Path | None = None, +) -> Figure: + """One panel per device: the time-averaged residual before and after, across the whole farm. + + The overview that answers "did anything move that should not have?" at a glance -- a device + whose corrected trace leaves the band, or whose changepoints do not line up with a visible + step, is the one to open :func:`plot_northing` on. + """ + index = pd.DatetimeIndex(index) + devices = sorted(direction_deg) + columns = 3 + rows = int(np.ceil(len(devices) / columns)) + fig, axes = plt.subplots(rows, columns, figsize=(5.2 * columns, 2.2 * rows), sharex=True, squeeze=False) + for ax, device in zip(axes.ravel(), devices, strict=False): + ok = np.asarray(usable[device], dtype=bool) + raw = np.asarray(direction_deg[device], dtype=float) + corrected = apply_north_table(index, raw, north_table=north_tables[device]) + before = pd.Series(np.where(ok, circ_diff(raw, reference_deg), np.nan), index=index) + after = pd.Series(np.where(ok, circ_diff(corrected, reference_deg), np.nan), index=index) + smoothed_before = _time_averaged(before, average=average) + smoothed_after = _time_averaged(after, average=average) + ax.plot(smoothed_before.index, smoothed_before.to_numpy(), color="tab:red", linewidth=1.0) + ax.plot(smoothed_after.index, smoothed_after.to_numpy(), color="tab:blue", linewidth=1.4) + ax.axhspan(-BELIEVABLE_DEG, BELIEVABLE_DEG, color="tab:blue", alpha=0.12) + ax.axhline(0.0, color="k", linewidth=0.6) + for changepoint in pd.DatetimeIndex(north_tables[device][TIMESTAMP_COL])[1:]: + ax.axvline(changepoint, color="tab:orange", linewidth=1.0, linestyle=":") + ax.set_ylim(-15, 15) + ax.set_title(f"{device} ({len(north_tables[device]) - 1} cp)", fontsize="small") + ax.grid(alpha=0.3) + ax.tick_params(labelsize="x-small") + for ax in axes.ravel()[len(devices) :]: + ax.set_visible(False) + fig.suptitle( + f"Northing across the farm vs {reference_name}: {_describe(average)} median residual, " + f"red before / blue after, band +/-{BELIEVABLE_DEG:.0f} deg" + ) + fig.tight_layout() + if out_dir is not None: + out_dir.mkdir(parents=True, exist_ok=True) + fig.savefig(out_dir / "farm_northing.png", dpi=120) + return fig diff --git a/tests/test_data/hot/northing/northing_inputs.parquet b/tests/test_data/hot/northing/northing_inputs.parquet new file mode 100644 index 00000000..7ab2c1ac --- /dev/null +++ b/tests/test_data/hot/northing/northing_inputs.parquet @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae4a6ffcb0bfc8d2c4c0ae392d594dc1fe95537de75e4d3fa447f8c6cc463a9d +size 15377978 diff --git a/tests/wind_up/test_northing.py b/tests/wind_up/test_northing.py index 1c380134..ed7f7dac 100644 --- a/tests/wind_up/test_northing.py +++ b/tests/wind_up/test_northing.py @@ -502,3 +502,37 @@ def test_an_oscillation_biased_enough_to_shift_the_level_is_not_fully_ironed_out steps.extend((when, 7.0 if i % 2 == 0 else 0.0) for i, when in enumerate(dates)) kept = self._n_changepoints(steps) assert 0 < kept < len(steps) - 1 + + +class TestNearTheRecordEdge: + """How much data sits either side of a changepoint decides how big a step is credible. + + A step with little record after it is estimated from little data, so a small one is as + likely to be veer as a recalibration. A large one is not: no amount of veer moves a + turbine's yaw by tens of degrees, so it must still be found however late it lands. + """ + + @staticmethod + def _n_changepoints(step_deg: float, *, days_after: float, days: float = 700.0) -> int: + index = _index(days=days) + when = (index.max() - pd.Timedelta(days=days_after)).strftime("%Y-%m-%d") + reported, reference = _reported(index, steps=[("2017-01-01", 0.0), (when, step_deg)]) + table = estimate_north_table(index, reported, reference_deg=reference, usable=_all_usable(index)) + return len(table) - 1 + + def test_a_large_jump_ten_days_before_the_end_is_still_found(self) -> None: + assert self._n_changepoints(60.0, days_after=10.0) == 1 + + def test_a_large_jump_ten_days_after_the_start_is_still_found(self) -> None: + index = _index(days=700) + when = (index.min() + pd.Timedelta(days=10)).strftime("%Y-%m-%d") + reported, reference = _reported(index, steps=[("2017-01-01", 0.0), (when, 60.0)]) + table = estimate_north_table(index, reported, reference_deg=reference, usable=_all_usable(index)) + assert len(table) - 1 == 1 + + def test_a_small_step_ten_days_before_the_end_is_not_reported(self) -> None: + assert self._n_changepoints(4.0, days_after=10.0) == 0 + + def test_the_same_small_step_well_inside_the_record_is_reported(self) -> None: + """The step is identical; only the evidence behind it differs.""" + assert self._n_changepoints(4.0, days_after=300.0) == 1 diff --git a/tests/wind_up/test_northing_real_data.py b/tests/wind_up/test_northing_real_data.py new file mode 100644 index 00000000..761b5626 --- /dev/null +++ b/tests/wind_up/test_northing_real_data.py @@ -0,0 +1,170 @@ +"""Northing regression tests on real Hill of Towie data. + +Synthetic tests pin the algorithm's contract; only real SCADA exercises what it does with site +veer, outages and a reference that is itself derived from the farm. The fixture holds just what +the estimator consumes -- timestamp, raw yaw, the farm-direction reference, and whether that +reference had fallen back to reanalysis -- for six turbines over 2016-2020. + +Two groups of test, and the distinction matters: + +* **known changepoints** -- recalibrations that v0's published table also records. These must + keep being found; they are what any change to the estimator must not break. +* **artefacts** -- changepoints that are not real, established by showing they appear and + disappear with the *window* rather than with the data. A record ending days after an apparent + step is the clearest case: extend it and the step is gone. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from wind_up.circular_math import circ_diff +from wind_up.northing import estimate_north_table + +FIXTURE = Path(__file__).parents[1] / "test_data" / "hot" / "northing" / "northing_inputs.parquet" + +pytestmark = pytest.mark.skipif( + not FIXTURE.exists(), reason="Hill of Towie northing fixture not available (git-lfs not pulled)" +) + + +@pytest.fixture(scope="module") +def hot() -> pd.DataFrame: + """The northing fixture, loaded once for the module.""" + return pd.read_parquet(FIXTURE) + + +def _changepoints( + hot: pd.DataFrame, + turbine: str, + start: str, + end: str, + *, + exclude_fallback: bool = False, +) -> list[tuple[pd.Timestamp, float]]: + """Return ``(timestamp, step_deg)`` for each changepoint the estimator finds in a window.""" + rows = hot[ + (hot["turbine"] == turbine) + & (hot["timestamp"] >= pd.Timestamp(start, tz="UTC")) + & (hot["timestamp"] < pd.Timestamp(end, tz="UTC")) + ] + usable = np.ones(len(rows), dtype=bool) + if exclude_fallback: + usable &= ~rows["reference_is_fallback"].to_numpy() + table = estimate_north_table( + pd.DatetimeIndex(rows["timestamp"]), + rows["yaw_deg"].to_numpy(dtype=float), + reference_deg=rows["farm_reference_deg"].to_numpy(dtype=float), + usable=usable, + ) + offsets = table["north_offset"].to_numpy(dtype=float) + return [(table["timestamp"].iloc[i], float(circ_diff(offsets[i], offsets[i - 1]))) for i in range(1, len(table))] + + +def _assert_matches( + found: list[tuple[pd.Timestamp, float]], + expected: list[tuple[str, float]], + *, + days: float = 2.0, + step_deg: float = 2.0, +) -> None: + """Assert the found changepoints match ``expected`` in count, date and step size.""" + assert len(found) == len(expected), f"expected {len(expected)} changepoint(s), got {_describe(found)}" + for (when, step), (expected_when, expected_step) in zip(found, expected, strict=True): + assert abs(when - pd.Timestamp(expected_when, tz="UTC")) <= pd.Timedelta(days=days), _describe(found) + assert circ_diff(step, expected_step) == pytest.approx(0.0, abs=step_deg), _describe(found) + + +def _describe(found: list[tuple[pd.Timestamp, float]]) -> str: + return str([(w.strftime("%Y-%m-%d"), round(s, 1)) for w, s in found]) + + +class TestKnownChangepoints: + """Real recalibrations v0's published table also records. These must keep being found.""" + + def test_t01_two_steps_in_spring_2017(self, hot: pd.DataFrame) -> None: + _assert_matches( + _changepoints(hot, "T01", "2017-01-01", "2019-01-01"), + [("2017-04-23", 21.0), ("2017-05-04", -19.2)], + ) + + def test_t05_a_large_step_then_a_partial_reversal_a_year_later(self, hot: pd.DataFrame) -> None: + _assert_matches( + _changepoints(hot, "T05", "2017-01-01", "2019-01-01"), + [("2017-05-03", 35.8), ("2018-04-21", -19.7)], + ) + + def test_t16_a_ninety_degree_recalibration_and_two_small_follow_ups(self, hot: pd.DataFrame) -> None: + """The large step and its near-reversal must both survive: size is what makes them real.""" + _assert_matches( + _changepoints(hot, "T16", "2017-01-01", "2019-01-01"), + [("2017-05-19", 98.6), ("2017-06-18", 9.0), ("2017-08-09", -7.2)], + ) + + @pytest.mark.parametrize("turbine", ["T07", "T11"]) + def test_a_stable_turbine_gets_no_changepoints(self, hot: pd.DataFrame, turbine: str) -> None: + found = _changepoints(hot, turbine, "2017-01-01", "2019-01-01") + assert found == [], _describe(found) + + +class TestEdgeArtefacts: + """A step near the end of a record is only credible if it is big. + + T13 is the case: an apparent +3.5 deg step on 2018-12-20 that exists only when the record + stops twelve days later. It is not in v0's published table, and extending the record by two + days removes it -- a step in the data would not care where the record happens to end. + """ + + def test_a_small_step_just_before_the_record_ends_is_not_reported(self, hot: pd.DataFrame) -> None: + found = _changepoints(hot, "T13", "2017-01-01", "2019-01-01") + assert found == [], _describe(found) + + def test_the_same_step_with_a_month_of_data_after_it_is_not_reported(self, hot: pd.DataFrame) -> None: + found = _changepoints(hot, "T13", "2017-01-01", "2019-02-01") + assert found == [], _describe(found) + + def test_extending_the_record_by_two_days_already_removed_it(self, hot: pd.DataFrame) -> None: + """The control: this window has always been clean, and must stay clean.""" + found = _changepoints(hot, "T13", "2017-01-01", "2019-01-03") + assert found == [], _describe(found) + + def test_a_clean_two_year_window_stays_clean(self, hot: pd.DataFrame) -> None: + found = _changepoints(hot, "T13", "2016-01-01", "2018-01-01") + assert found == [], _describe(found) + + +class TestReferenceFallback: + """Where the farm reference silently became reanalysis, the residual is not comparable. + + v0's ``add_wf_yawdir`` fills a missing farm direction with reanalysis, which sits degrees + away from the farm consensus, so every turbine appears to step together. Dropping those rows + is the caller's job -- the estimator only sees ``usable``. + """ + + def test_excluding_fallback_rows_removes_the_august_2020_pair(self, hot: pd.DataFrame) -> None: + window = ("2019-06-01", "2020-09-01") + with_fallback = _changepoints(hot, "T13", *window) + without = _changepoints(hot, "T13", *window, exclude_fallback=True) + assert len(without) < len(with_fallback), f"{_describe(with_fallback)} -> {_describe(without)}" + assert not any(w >= pd.Timestamp("2020-08-01", tz="UTC") for w, _ in without), _describe(without) + + @pytest.mark.xfail( + reason="the farm reference is not one quantity when its composition changes; see the " + "'reference composition' limitation in the R1 design", + strict=True, + ) + def test_the_farm_wide_outage_excursions_should_not_be_reported(self, hot: pd.DataFrame) -> None: + """Nov 2019 and Jun 2020: nearly every turbine steps together and back within ~8 days. + + Those weeks are farm outages. With few turbines reporting, the farm median is taken over a + different subset than usual, and since turbines have different veer signatures the + reference itself shifts -- so every turbine appears to step. Excluding the rows where the + reference fell back to reanalysis removes some of it but not these two pairs, because the + fallback never triggers: three turbines still report, just not the usual three. + """ + found = _changepoints(hot, "T13", "2019-06-01", "2020-09-01", exclude_fallback=True) + assert found == [], _describe(found) From 4a640a905817a3a987be7dbf3c98f81d09d2312b Mon Sep 17 00:00:00 2001 From: aclerc Date: Wed, 2 Sep 2026 18:41:19 +0100 Subject: [PATCH 03/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- .../specs/2026-09-02-r1-northing-design.md | 75 ++++- src/wind_up/northing.py | 125 ++++++-- .../hot/northing/northing_inputs.parquet | 4 +- tests/wind_up/test_northing.py | 82 ++++++ tests/wind_up/test_northing_real_data.py | 268 ++++++++++-------- 5 files changed, 409 insertions(+), 145 deletions(-) diff --git a/docs/superpowers/specs/2026-09-02-r1-northing-design.md b/docs/superpowers/specs/2026-09-02-r1-northing-design.md index 583699cf..126d4fb4 100644 --- a/docs/superpowers/specs/2026-09-02-r1-northing-design.md +++ b/docs/superpowers/specs/2026-09-02-r1-northing-design.md @@ -261,7 +261,80 @@ real detection**: T16's genuine +9.0° step on 2017-06-18 has only 30 days befor The scaled rule requires 5.2° there and keeps it, requires 8.2° at T13's twelve days and drops it. The cap at `max_transient_step_deg` keeps a large late jump findable, which a unit test pins. -### The outage artefact (documented, not fixed) +### The outage artefact (fixed) -- and it was in the first pass all along + +Over 2016-2024 nearly every turbine gained changepoint pairs at **2019-11-11/19**, **2020-06-12/19** +and **2023-06** -- steps of 12-22 degrees, farm-wide, synchronous, self-cancelling within about +eight days. **143 changepoints against v0's 28**, with 111 of the 127 extras in just three months. +They survived the excursion filter because their size is above `max_transient_step_deg`. + +Three hypotheses were tested and two were wrong, which is worth recording because each looked +convincing: + +1. **Silent reference substitution.** v0's `add_wf_yawdir` fills a missing farm direction with + reanalysis, and in the excursion weeks 35% and 51% of rows are that fallback. Excluding them + removes the August 2020 pair and **nothing else**. +2. **Changing reference composition.** Plausible -- turbines have different veer, so a median over + a shrinking subset should drift. Measured and **false**: after the first pass every device's + long-run offset from the farm median is within -0.3 to +0.5 degrees, and centring the devices + moves the median by 0.00 degrees in every window. +3. **The first pass.** During the June 2020 excursion `northed - 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. + +The cause is that **reanalysis has its own direction-dependent bias**. A spell of unusual wind -- +the June 2020 week was easterly, a sector Hill of Towie rarely sees -- moves every turbine's +residual against reanalysis together, by tens of degrees. The first pass corrected for that, which +wrote the excursion into the northed directions and from there into the farm consensus the second +pass trusts. The outage correlates only because both are weather. + +**Fix: the first pass may act only on a gross recalibration** (`ANCHORING_MIN_STEP_DEG`, 30 +degrees). Its job is to fix the farm in absolute terms, and a step smaller than that is better +left to the second pass, which works against the clean farm consensus and estimates from the raw +direction so nothing is lost by deferring it. The bar sits above reanalysis' own excursions +(~20 degrees) and below a real gross recalibration (Hill of Towie's are 36-177 degrees). + +Blocking the first pass entirely was tried and **rejected**: with only four devices, one +uncorrected 40-degree step drags the median enough to break a real detection. + +A quorum was added alongside -- the consensus needs a strict majority of the farm reporting, not +a floor of three -- because a median over a handful of devices is not the farm's. On its own it +moved the farm total by 3 (143 to 140); it earns its place for the subset case rather than this one. + +### Measured effect of the two fixes + +Hill of Towie, all 21 turbines, **2016-2024**, against v0's published table: + +| | before | after | +|---|---|---| +| changepoints found | 143 | **19** (v0: 28) | +| of which not in v0's table | **127** | **2** | +| v0's changepoints recovered | 16/28 | 17/28 | + +Across a 99-case subset sweep (3 turbine groups x 33 windows, 3 months to 9 years, definitions in +`study/subsets.py` so it re-runs after any change): + +| | before | after | +|---|---|---| +| cases finding changepoints the full run does not | 40/98 | **19/98** | +| total such extras | 287 | **32** | + +The eleven v0 entries not recovered are all small (1.2-8.6 degrees) and mostly one sequence -- +T01's four sub-2.5-degree steps in early 2016 -- which has the signature of veer being chased +rather than a recalibration. + +### What is still imperfect + +`west__year_2021` finds six changepoints the full run does not, and windows starting immediately +after a recalibration (`edge_after_t16_recal`) disagree in both directions. These are recorded +rather than fixed: the analyst inspects the northing result and can supply a hand-corrected +table, so the corrector does not have to be right every time -- it has to be right usually, and +**visible** when it is not, which is what the plots are for. + +### The earlier framing (superseded) + +Kept because the reasoning is instructive, not because it was right: it named the outage as the +cause and the first pass as innocent, and both were wrong. Over 2016–2020 nearly every turbine gains changepoint pairs at **2019-11-11/19** and **2020-06-12/19** — steps of ~±12° and ~±16°, farm-wide, synchronous, self-cancelling within diff --git a/src/wind_up/northing.py b/src/wind_up/northing.py index d81ee076..41e7564d 100644 --- a/src/wind_up/northing.py +++ b/src/wind_up/northing.py @@ -59,6 +59,15 @@ # A step larger than this is a recalibration whatever else the record does, so it is never ironed # out as wander -- real ones do sometimes reverse later. _MAX_TRANSIENT_STEP_DEG = 10.0 + + +# A consensus needs a strict majority of the farm reporting. Below that the median is over an +# unrepresentative few, whose own veer moves the reference rather than the farm's. +def _farm_quorum(n_devices: int, *, floor: int) -> int: + """Return how many devices must report for their median to stand for the farm's consensus.""" + return max(floor, n_devices // 2 + 1) + + # The span either side of a changepoint at which ``min_step_deg`` applies unmodified. With less # record than this the level is veer-limited rather than sample-limited, so a bigger step is # needed to tell a recalibration from the wander. @@ -112,10 +121,31 @@ class NorthingSettings: # consensus shares that common-mode error, so against one a residual step really is the # turbine's. See :func:`against_reanalysis`. REANALYSIS_MIN_STEP_DEG = 10.0 +# The first pass may only act on a *gross* recalibration -- one large enough that leaving it +# uncorrected would drag the farm consensus the second pass depends on. Reanalysis' own +# direction-dependent bias moves every turbine together by up to ~20 degrees during a spell of +# unusual wind, so the bar sits above that. +ANCHORING_MIN_STEP_DEG = 30.0 DEFAULT_NORTHING = NorthingSettings() +def anchoring_only(settings: NorthingSettings) -> NorthingSettings: + """Return ``settings`` reduced to what the first pass is for: anchoring, not changepoint work. + + The first pass exists to fix the farm in absolute terms against reanalysis. Reanalysis has its + own direction-dependent bias, so a spell of unusual wind moves every turbine's residual + against it together, by tens of degrees -- and acting on that writes the artefact into the + corrected directions and from there into the farm consensus the second pass trusts. + + So only a **gross** step is acted on here (:data:`ANCHORING_MIN_STEP_DEG`): large enough that + leaving it would drag the consensus, and larger than reanalysis' own excursions. Everything + finer is left to the second pass, which works against the farm consensus and estimates from + the **raw** direction, so nothing is lost by deferring it. + """ + return replace(against_reanalysis(settings), min_step_deg=ANCHORING_MIN_STEP_DEG) + + def against_reanalysis(settings: NorthingSettings) -> NorthingSettings: """Return ``settings`` made safe for northing against reanalysis rather than a farm consensus. @@ -212,23 +242,46 @@ def veer_normalised( sector levels are measured on it rather than on ``residual``, so a large step cannot leak into the veer signature. Defaults to ``residual`` itself. """ - finite = np.isfinite(residual) & np.isfinite(reference_deg) + signature = sector_signature( + residual if de_stepped is None else de_stepped, + reference_deg=reference_deg, + sector_deg=sector_deg, + min_rows_per_sector=min_rows_per_sector, + ) + finite = np.isfinite(residual) & np.isfinite(signature) + out = residual.copy() + out[finite] = np.asarray(circ_diff(residual[finite], signature[finite]), dtype=float) + return out + + +def sector_signature( + values_deg: npt.NDArray[np.float64], + *, + reference_deg: npt.NDArray[np.float64], + sector_deg: float, + min_rows_per_sector: int = _MIN_ROWS_PER_SECTOR, +) -> npt.NDArray[np.float64]: + """Return the long-run level of ``values_deg`` in each row's direction sector, per row. + + This is the veer signature: how far this device sits from the reference when the wind comes + from each direction. Sectors with too little data fall back to the overall level; rows with + no usable direction get NaN. + """ + finite = np.isfinite(values_deg) & np.isfinite(reference_deg) if not finite.any(): - return residual - measured_on = residual if de_stepped is None else de_stepped + return np.full(len(values_deg), np.nan) n_sectors = max(1, int(np.ceil(360.0 / sector_deg))) - sector = np.zeros(len(residual), dtype=int) + sector = np.zeros(len(values_deg), dtype=int) sector[finite] = (np.mod(reference_deg[finite], 360.0) // sector_deg).astype(int) % n_sectors - overall = float(circ_median(measured_on[finite], range_360=False)) + overall = float(circ_median(values_deg[finite], range_360=False)) level = np.full(n_sectors, overall) for s in range(n_sectors): - rows = finite & (sector == s) & np.isfinite(measured_on) + rows = finite & (sector == s) if int(rows.sum()) >= min_rows_per_sector: - level[s] = float(circ_median(measured_on[rows], range_360=False)) - - out = residual.copy() - out[finite] = np.asarray(circ_diff(residual[finite], level[sector[finite]]), dtype=float) + level[s] = float(circ_median(values_deg[rows], range_360=False)) + out = np.full(len(values_deg), np.nan) + out[np.isfinite(reference_deg)] = level[sector[np.isfinite(reference_deg)]] return out @@ -656,27 +709,14 @@ def apply_north_table( return np.where(np.isfinite(direction), (direction + offsets[which]) % 360.0, np.nan) -def _farm_direction( - northed: Mapping[str, npt.NDArray[np.float64]], - *, - usable: Mapping[str, npt.NDArray[np.bool_]], - min_devices: int, -) -> npt.NDArray[np.float64]: - """Per-timestamp circular median of the devices' northed directions, NaN where too few.""" - stack = np.vstack( - [np.where(usable[name] & np.isfinite(values), values, np.nan) for name, values in northed.items()] - ) - present = np.isfinite(stack).sum(axis=0) +def _median_across(stack: npt.NDArray[np.float64], *, enough: npt.NDArray[np.bool_]) -> npt.NDArray[np.float64]: + """Per-timestamp circular median down a devices x time stack, NaN where ``enough`` is False.""" farm = np.full(stack.shape[1], np.nan) - enough = present >= min_devices if not enough.any(): return farm - columns = stack[:, enough] rad = np.deg2rad(columns) - # nan-aware circular mean, then the median of the values centred on it - finite = np.isfinite(columns) - counts = finite.sum(axis=0) + counts = np.isfinite(columns).sum(axis=0) mean = np.degrees( np.arctan2( np.nansum(np.sin(rad), axis=0) / counts, @@ -689,6 +729,26 @@ def _farm_direction( return farm +def _farm_direction( + northed: Mapping[str, npt.NDArray[np.float64]], + *, + usable: Mapping[str, npt.NDArray[np.bool_]], + min_devices: int, +) -> npt.NDArray[np.float64]: + """Per-timestamp circular median of the devices' northed directions, NaN where too few report. + + ``min_devices`` is what keeps this trustworthy. Devices differ from the consensus by their own + direction-dependent veer, so a median over only a few of them is not the farm's consensus -- + and when an outage coincides with an unusual wind direction, every device appears to step at + once and back again. The guard is a quorum rather than a floor: see :func:`north_farm`. + """ + stack = np.vstack( + [np.where(usable[name] & np.isfinite(values), values, np.nan) for name, values in northed.items()] + ) + enough = np.isfinite(stack).sum(axis=0) >= min_devices + return _median_across(stack, enough=enough) + + def north_farm( index: pd.DatetimeIndex, *, @@ -712,8 +772,12 @@ def north_farm( :param direction_deg: device name to its raw direction signal :param usable: device name to the rows usable for northing it :param reanalysis_deg: the absolute direction reference, on ``index`` - :param min_devices_for_farm_reference: devices that must report at a timestamp for the - consensus to be defined there; also the minimum farm size + :param min_devices_for_farm_reference: the floor on how many devices must report at a + timestamp for the consensus to be defined there, and the minimum farm size. The effective + requirement is the larger of this and a strict majority of the farm: a median over an + unrepresentative few + carries their veer rather than the farm's, which is what makes an outage look like every + turbine stepping at once. """ devices = sorted(direction_deg) if len(devices) < min_devices_for_farm_reference: @@ -729,7 +793,7 @@ def north_farm( # Pass 1's reference is reanalysis, so it may only attribute large steps; pass 2's farm # consensus is clean enough for the caller's chosen threshold. - anchoring = against_reanalysis(settings) + anchoring = anchoring_only(settings) first_pass = { name: estimate_north_table( index, @@ -741,7 +805,8 @@ def north_farm( for name in devices } northed = {name: apply_north_table(index, direction_deg[name], north_table=first_pass[name]) for name in devices} - farm = _farm_direction(northed, usable=usable, min_devices=min_devices_for_farm_reference) + quorum = _farm_quorum(len(devices), floor=min_devices_for_farm_reference) + farm = _farm_direction(northed, usable=usable, min_devices=quorum) if not np.isfinite(farm).any(): logger.warning("farm reference is empty; keeping the reanalysis-only north tables") return first_pass diff --git a/tests/test_data/hot/northing/northing_inputs.parquet b/tests/test_data/hot/northing/northing_inputs.parquet index 7ab2c1ac..3d13006e 100644 --- a/tests/test_data/hot/northing/northing_inputs.parquet +++ b/tests/test_data/hot/northing/northing_inputs.parquet @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ae4a6ffcb0bfc8d2c4c0ae392d594dc1fe95537de75e4d3fa447f8c6cc463a9d -size 15377978 +oid sha256:fdef7deb15d0d435e52032f523e0d7785988eac6513a7b01740144b9e40118d1 +size 14015490 diff --git a/tests/wind_up/test_northing.py b/tests/wind_up/test_northing.py index ed7f7dac..cbe098a4 100644 --- a/tests/wind_up/test_northing.py +++ b/tests/wind_up/test_northing.py @@ -536,3 +536,85 @@ def test_a_small_step_ten_days_before_the_end_is_not_reported(self) -> None: def test_the_same_small_step_well_inside_the_record_is_reported(self) -> None: """The step is identical; only the evidence behind it differs.""" assert self._n_changepoints(4.0, days_after=300.0) == 1 + + +class TestFarmReferenceComposition: + """The farm reference must not depend on *which* devices happened to report. + + Turbines sit at different long-run offsets from the farm consensus -- site veer. A plain + median over whoever is reporting therefore moves when the reporting set changes, so an + outage, or simply analysing a subset of the farm, looks like every turbine stepping at once. + Nothing about any turbine's north calibration has changed, so nothing should be found. + """ + + @staticmethod + def _farm(index: pd.DatetimeIndex, *, veer: dict[str, float]) -> tuple[dict, np.ndarray]: + """Devices whose veer offset **depends on wind direction**, as real site veer does. + + A fixed per-device offset reproduces nothing: the first pass norths every device to + reanalysis and removes it, which is why an early attempt at this test passed against the + very bug it was written for. What survives that pass is the direction-dependent *shape*, + and that is what moves the reference when the reporting set and the wind direction change + together. + """ + reference = _true_direction(index, seed=3) + reported = {} + for i, (name, amplitude) in enumerate(veer.items()): + shape = amplitude * np.cos(np.deg2rad(reference - 60.0 * i)) + scatter = np.random.default_rng(200 + i).normal(0.0, 5.0, len(index)) + reported[name] = (reference + shape + scatter) % 360.0 + return reported, reference + + def test_no_changepoint_when_an_outage_coincides_with_an_unusual_wind_direction(self) -> None: + """The Hill of Towie failure, in miniature. + + For one week most of the farm is down and the wind sits in a sector it rarely occupies. + The few devices still reporting have their own veer in that sector, so a median over them + is not the farm's consensus -- and every device appears to step together and back. + """ + index = _index(days=700) + veer = {"T01": 5.0, "T02": 4.0, "T03": 3.0, "T04": 4.5, "T05": 5.5, "T06": 3.5} + reported, reference = self._farm(index, veer=veer) + outage = (index >= index.min() + pd.Timedelta(days=350)) & (index < index.min() + pd.Timedelta(days=357)) + still_on = ("T01", "T02", "T03") + usable = {name: np.asarray(~outage | np.isin(name, still_on), dtype=bool) for name in reported} + + tables = north_farm( + index, direction_deg=reported, usable=usable, reanalysis_deg=reference, settings=DEFAULT_NORTHING + ) + + # Nothing may be attributed to the outage. A marginal detection elsewhere in the record is + # ordinary veer sensitivity, not this failure, so the assertion is placed where the bug is. + window = pd.Timedelta(days=14) + near = { + name: [ + c.strftime("%Y-%m-%d") + for c in pd.DatetimeIndex(table["timestamp"])[1:] + if index.min() + pd.Timedelta(days=350) - window <= c <= index.min() + pd.Timedelta(days=357) + window + ] + for name, table in tables.items() + } + offenders = {name: found for name, found in near.items() if found} + assert offenders == {}, f"the reporting set changed, not the turbines: {offenders}" + + def test_the_reference_gives_the_same_answer_for_a_subset_of_the_farm(self) -> None: + """Northing three of six devices must agree with northing all six.""" + index = _index(days=700) + veer = {"T01": 5.0, "T02": 4.0, "T03": 3.0, "T04": 4.5, "T05": 5.5, "T06": 3.5} + reported, reference = self._farm(index, veer=veer) + usable = {name: _all_usable(index) for name in reported} + subset = ("T01", "T02", "T03") + + whole = north_farm(index, direction_deg=reported, usable=usable, reanalysis_deg=reference) + part = north_farm( + index, + direction_deg={k: reported[k] for k in subset}, + usable={k: usable[k] for k in subset}, + reanalysis_deg=reference, + ) + + for name in subset: + assert len(part[name]) == len(whole[name]), name + assert part[name]["north_offset"].iloc[0] == pytest.approx(whole[name]["north_offset"].iloc[0], abs=2.0), ( + name + ) diff --git a/tests/wind_up/test_northing_real_data.py b/tests/wind_up/test_northing_real_data.py index 761b5626..10484a72 100644 --- a/tests/wind_up/test_northing_real_data.py +++ b/tests/wind_up/test_northing_real_data.py @@ -1,17 +1,22 @@ -"""Northing regression tests on real Hill of Towie data. +"""Northing regression tests on real Hill of Towie data, end to end through both passes. Synthetic tests pin the algorithm's contract; only real SCADA exercises what it does with site -veer, outages and a reference that is itself derived from the farm. The fixture holds just what -the estimator consumes -- timestamp, raw yaw, the farm-direction reference, and whether that -reference had fallen back to reanalysis -- for six turbines over 2016-2020. - -Two groups of test, and the distinction matters: - -* **known changepoints** -- recalibrations that v0's published table also records. These must - keep being found; they are what any change to the estimator must not break. -* **artefacts** -- changepoints that are not real, established by showing they appear and - disappear with the *window* rather than with the data. A record ending days after an apparent - step is the clearest case: extend it and the step is gone. +veer, farm outages and a reference derived from the farm itself. The fixture holds the raw +inputs -- each turbine's yaw and the reanalysis direction, over the rows where the turbine was +generating -- for all 21 turbines across 2017-2020, so a test runs :func:`north_farm` exactly as +a user would rather than trusting a precomputed reference. + +That distinction earned itself: an earlier version of this fixture stored the farm reference, +which had been built with the very first-pass bug these tests exist to catch, so the tests could +not see it. + +Three groups, and the distinction is the point: + +* **known changepoints** -- recalibrations v0's published table also records. They must keep + being found; they are what any change to the estimator must not break. +* **edge artefacts** -- changepoints that appear only because of where the record stops. +* **outage artefacts** -- farm-wide excursions during outages, which are the weather and the + reference moving together rather than any turbine's calibration. """ from __future__ import annotations @@ -23,9 +28,10 @@ import pytest from wind_up.circular_math import circ_diff -from wind_up.northing import estimate_north_table +from wind_up.northing import estimate_north_table, north_farm FIXTURE = Path(__file__).parents[1] / "test_data" / "hot" / "northing" / "northing_inputs.parquet" +ALL_TURBINES = tuple(f"T{n:02d}" for n in range(1, 22)) pytestmark = pytest.mark.skipif( not FIXTURE.exists(), reason="Hill of Towie northing fixture not available (git-lfs not pulled)" @@ -38,133 +44,171 @@ def hot() -> pd.DataFrame: return pd.read_parquet(FIXTURE) -def _changepoints( - hot: pd.DataFrame, - turbine: str, - start: str, - end: str, - *, - exclude_fallback: bool = False, -) -> list[tuple[pd.Timestamp, float]]: - """Return ``(timestamp, step_deg)`` for each changepoint the estimator finds in a window.""" +def _arrays(hot: pd.DataFrame, turbines: tuple[str, ...], start: str, end: str) -> tuple: + """Return ``(index, direction, usable, reanalysis)`` for a window, as ``north_farm`` wants them.""" rows = hot[ - (hot["turbine"] == turbine) + hot["turbine"].isin(turbines) & (hot["timestamp"] >= pd.Timestamp(start, tz="UTC")) & (hot["timestamp"] < pd.Timestamp(end, tz="UTC")) ] - usable = np.ones(len(rows), dtype=bool) - if exclude_fallback: - usable &= ~rows["reference_is_fallback"].to_numpy() - table = estimate_north_table( - pd.DatetimeIndex(rows["timestamp"]), - rows["yaw_deg"].to_numpy(dtype=float), - reference_deg=rows["farm_reference_deg"].to_numpy(dtype=float), - usable=usable, - ) + index = pd.DatetimeIndex(sorted(rows["timestamp"].unique())) + direction, usable, reanalysis = {}, {}, None + for turbine in sorted(rows["turbine"].unique()): + one = rows[rows["turbine"] == turbine].drop_duplicates("timestamp").set_index("timestamp").reindex(index) + wd = one["era5_wd_deg"].to_numpy(dtype=float) + reanalysis = wd if reanalysis is None else np.where(np.isfinite(reanalysis), reanalysis, wd) + yaw = one["yaw_deg"].to_numpy(dtype=float) + direction[str(turbine)] = yaw + usable[str(turbine)] = np.isfinite(yaw) & np.isfinite(wd) + return index, direction, usable, reanalysis + + +def _changepoints(table: pd.DataFrame) -> list[tuple[pd.Timestamp, float]]: offsets = table["north_offset"].to_numpy(dtype=float) return [(table["timestamp"].iloc[i], float(circ_diff(offsets[i], offsets[i - 1]))) for i in range(1, len(table))] -def _assert_matches( - found: list[tuple[pd.Timestamp, float]], - expected: list[tuple[str, float]], - *, - days: float = 2.0, - step_deg: float = 2.0, -) -> None: - """Assert the found changepoints match ``expected`` in count, date and step size.""" - assert len(found) == len(expected), f"expected {len(expected)} changepoint(s), got {_describe(found)}" - for (when, step), (expected_when, expected_step) in zip(found, expected, strict=True): - assert abs(when - pd.Timestamp(expected_when, tz="UTC")) <= pd.Timedelta(days=days), _describe(found) - assert circ_diff(step, expected_step) == pytest.approx(0.0, abs=step_deg), _describe(found) - - def _describe(found: list[tuple[pd.Timestamp, float]]) -> str: return str([(w.strftime("%Y-%m-%d"), round(s, 1)) for w, s in found]) -class TestKnownChangepoints: - """Real recalibrations v0's published table also records. These must keep being found.""" +_RUNS: dict[tuple, dict[str, list[tuple[pd.Timestamp, float]]]] = {} + + +def run_farm( + hot: pd.DataFrame, turbines: tuple[str, ...], start: str, end: str +) -> dict[str, list[tuple[pd.Timestamp, float]]]: + """Changepoints per turbine for one (turbines, window), memoised -- each run costs seconds.""" + key = (turbines, start, end) + if key not in _RUNS: + index, direction, usable, reanalysis = _arrays(hot, turbines, start, end) + tables = north_farm(index, direction_deg=direction, usable=usable, reanalysis_deg=reanalysis) + _RUNS[key] = {name: _changepoints(table) for name, table in tables.items()} + return _RUNS[key] + + +# Two two-year windows rather than one four-year one: the changepoint search costs roughly the +# cube of the record length, so this covers the same events for a quarter of the runtime. +EARLY = ("2017-01-01", "2019-01-01") +LATE = ("2019-01-01", "2021-01-01") + +# Every recalibration v0's published table records in each window, and nothing else. +EXPECTED = { + EARLY: { + "T01": [("2017-04-23", 21.1), ("2017-05-04", -19.4)], + "T05": [("2017-05-03", 35.7), ("2018-04-21", -19.7)], + "T16": [("2017-05-19", 98.7), ("2017-06-18", 9.0), ("2017-08-09", -7.2)], + }, + LATE: { + "T11": [("2019-08-19", -4.4)], + "T12": [("2020-06-18", 170.7)], + "T19": [("2019-07-12", 98.8), ("2019-12-24", -122.5)], + }, +} +_CASES = [(window, turbine) for window, turbines in EXPECTED.items() for turbine in sorted(turbines)] +_QUIET = [ + (window, turbine) for window, turbines in EXPECTED.items() for turbine in ALL_TURBINES if turbine not in turbines +] - def test_t01_two_steps_in_spring_2017(self, hot: pd.DataFrame) -> None: - _assert_matches( - _changepoints(hot, "T01", "2017-01-01", "2019-01-01"), - [("2017-04-23", 21.0), ("2017-05-04", -19.2)], - ) - def test_t05_a_large_step_then_a_partial_reversal_a_year_later(self, hot: pd.DataFrame) -> None: - _assert_matches( - _changepoints(hot, "T05", "2017-01-01", "2019-01-01"), - [("2017-05-03", 35.8), ("2018-04-21", -19.7)], - ) +class TestKnownChangepoints: + """All 21 turbines over two-year windows: v0's changepoints, and no others.""" + + @pytest.mark.parametrize(("window", "turbine"), _CASES, ids=lambda v: v if isinstance(v, str) else v[0]) + def test_a_turbines_known_recalibrations_are_found( + self, hot: pd.DataFrame, window: tuple[str, str], turbine: str + ) -> None: + found = run_farm(hot, ALL_TURBINES, *window)[turbine] + expected = EXPECTED[window][turbine] + assert len(found) == len(expected), f"{turbine}: {_describe(found)}" + for (when, step), (expected_when, expected_step) in zip(found, expected, strict=True): + assert abs(when - pd.Timestamp(expected_when, tz="UTC")) <= pd.Timedelta(days=3), _describe(found) + assert circ_diff(step, expected_step) == pytest.approx(0.0, abs=3.0), _describe(found) + + @pytest.mark.parametrize(("window", "turbine"), _QUIET, ids=lambda v: v if isinstance(v, str) else v[0]) + def test_every_other_turbine_is_left_alone(self, hot: pd.DataFrame, window: tuple[str, str], turbine: str) -> None: + found = run_farm(hot, ALL_TURBINES, *window)[turbine] + assert found == [], f"{turbine}: {_describe(found)}" + + @pytest.mark.parametrize("window", [EARLY, LATE], ids=["early", "late"]) + def test_the_farm_total_matches_the_published_table(self, hot: pd.DataFrame, window: tuple[str, str]) -> None: + """v0's rate over 21 turbines, not an order more.""" + found = run_farm(hot, ALL_TURBINES, *window) + assert sum(len(v) for v in found.values()) == sum(len(v) for v in EXPECTED[window].values()), { + n: _describe(v) for n, v in found.items() if v + } + + +class TestOutageArtefacts: + """Farm-wide self-cancelling excursions must not be reported. + + November 2019 and June 2020 are spells where most of the farm is down and the wind sits in a + sector it rarely occupies. Every turbine appeared to step by 12-22 degrees and back within a + week. The cause was the **first** pass: reanalysis carries its own direction-dependent bias, + so an unusual spell moves every turbine's residual against it together, and correcting for + that wrote the excursion into the northed directions and hence into the farm consensus the + second pass trusts. + """ - def test_t16_a_ninety_degree_recalibration_and_two_small_follow_ups(self, hot: pd.DataFrame) -> None: - """The large step and its near-reversal must both survive: size is what makes them real.""" - _assert_matches( - _changepoints(hot, "T16", "2017-01-01", "2019-01-01"), - [("2017-05-19", 98.6), ("2017-06-18", 9.0), ("2017-08-09", -7.2)], - ) + OUTAGES = (("2019-11-05", "2019-11-25"), ("2020-06-08", "2020-06-17")) - @pytest.mark.parametrize("turbine", ["T07", "T11"]) - def test_a_stable_turbine_gets_no_changepoints(self, hot: pd.DataFrame, turbine: str) -> None: - found = _changepoints(hot, turbine, "2017-01-01", "2019-01-01") - assert found == [], _describe(found) + def test_no_turbine_steps_during_a_farm_outage(self, hot: pd.DataFrame) -> None: + during = { + name: [ + (w, s) + for w, s in found + if any(pd.Timestamp(lo, tz="UTC") <= w <= pd.Timestamp(hi, tz="UTC") for lo, hi in self.OUTAGES) + ] + for name, found in run_farm(hot, ALL_TURBINES, *LATE).items() + } + offenders = {name: _describe(v) for name, v in during.items() if v} + assert offenders == {}, f"turbines stepped with the outage, not their own calibration: {offenders}" + + def test_the_outage_years_are_quiet(self, hot: pd.DataFrame) -> None: + """Run 2019-2020 on its own: four changepoints across 21 turbines, all in v0's table.""" + found = run_farm(hot, ALL_TURBINES, *LATE) + total = sum(len(v) for v in found.values()) + assert total == 4, {n: _describe(v) for n, v in found.items() if v} class TestEdgeArtefacts: """A step near the end of a record is only credible if it is big. - T13 is the case: an apparent +3.5 deg step on 2018-12-20 that exists only when the record - stops twelve days later. It is not in v0's published table, and extending the record by two - days removes it -- a step in the data would not care where the record happens to end. + T13 had an apparent +3.5 deg step on 2018-12-20 that existed only when the record stopped + twelve days later; extending it by two days removed it. It is not in v0's table. """ - def test_a_small_step_just_before_the_record_ends_is_not_reported(self, hot: pd.DataFrame) -> None: - found = _changepoints(hot, "T13", "2017-01-01", "2019-01-01") + @pytest.mark.parametrize("end", ["2019-01-01", "2019-01-03", "2019-02-01"]) + def test_t13_is_clean_wherever_the_record_stops(self, hot: pd.DataFrame, end: str) -> None: + found = run_farm(hot, ALL_TURBINES, "2017-01-01", end)["T13"] assert found == [], _describe(found) - def test_the_same_step_with_a_month_of_data_after_it_is_not_reported(self, hot: pd.DataFrame) -> None: - found = _changepoints(hot, "T13", "2017-01-01", "2019-02-01") - assert found == [], _describe(found) - def test_extending_the_record_by_two_days_already_removed_it(self, hot: pd.DataFrame) -> None: - """The control: this window has always been clean, and must stay clean.""" - found = _changepoints(hot, "T13", "2017-01-01", "2019-01-03") - assert found == [], _describe(found) +class TestSubsetConsistency: + """Analysing part of a farm must not invent changepoints the whole farm does not see.""" - def test_a_clean_two_year_window_stays_clean(self, hot: pd.DataFrame) -> None: - found = _changepoints(hot, "T13", "2016-01-01", "2018-01-01") - assert found == [], _describe(found) + WEST = tuple(f"T{n:02d}" for n in range(1, 16)) + EAST = tuple(f"T{n:02d}" for n in range(16, 22)) + @pytest.mark.parametrize("half", ["west", "east"]) + def test_half_the_farm_agrees_with_the_whole(self, hot: pd.DataFrame, half: str) -> None: + turbines = self.WEST if half == "west" else self.EAST + tables = run_farm(hot, turbines, *EARLY) + reference = run_farm(hot, ALL_TURBINES, *EARLY) + for name in turbines: + found = tables[name] + whole = reference[name] + assert len(found) == len(whole), f"{name}: {half}={_describe(found)} whole={_describe(whole)}" -class TestReferenceFallback: - """Where the farm reference silently became reanalysis, the residual is not comparable. - v0's ``add_wf_yawdir`` fills a missing farm direction with reanalysis, which sits degrees - away from the farm consensus, so every turbine appears to step together. Dropping those rows - is the caller's job -- the estimator only sees ``usable``. - """ +class TestSingleTurbineAgainstReanalysis: + """Northing one turbine with no farm to lean on falls back to reanalysis alone.""" - def test_excluding_fallback_rows_removes_the_august_2020_pair(self, hot: pd.DataFrame) -> None: - window = ("2019-06-01", "2020-09-01") - with_fallback = _changepoints(hot, "T13", *window) - without = _changepoints(hot, "T13", *window, exclude_fallback=True) - assert len(without) < len(with_fallback), f"{_describe(with_fallback)} -> {_describe(without)}" - assert not any(w >= pd.Timestamp("2020-08-01", tz="UTC") for w, _ in without), _describe(without) - - @pytest.mark.xfail( - reason="the farm reference is not one quantity when its composition changes; see the " - "'reference composition' limitation in the R1 design", - strict=True, - ) - def test_the_farm_wide_outage_excursions_should_not_be_reported(self, hot: pd.DataFrame) -> None: - """Nov 2019 and Jun 2020: nearly every turbine steps together and back within ~8 days. - - Those weeks are farm outages. With few turbines reporting, the farm median is taken over a - different subset than usual, and since turbines have different veer signatures the - reference itself shifts -- so every turbine appears to step. Excluding the rows where the - reference fell back to reanalysis removes some of it but not these two pairs, because the - fallback never triggers: three turbines still report, just not the usual three. - """ - found = _changepoints(hot, "T13", "2019-06-01", "2020-09-01", exclude_fallback=True) - assert found == [], _describe(found) + def test_a_lone_turbine_still_finds_its_large_recalibration(self, hot: pd.DataFrame) -> None: + index, direction, usable, reanalysis = _arrays(hot, ("T16",), "2017-01-01", "2019-01-01") + table = estimate_north_table(index, direction["T16"], reference_deg=reanalysis, usable=usable["T16"]) + found = _changepoints(table) + assert len(found) >= 1, _describe(found) + assert any(abs(w - pd.Timestamp("2017-05-19", tz="UTC")) <= pd.Timedelta(days=3) for w, _ in found), _describe( + found + ) From 1f4c68916c7496a4dc3dfe65221c64e7e3ba072e Mon Sep 17 00:00:00 2001 From: aclerc Date: Wed, 2 Sep 2026 18:55:24 +0100 Subject: [PATCH 04/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- src/wind_up/northing.py | 172 ++++++++++++++-------------- src/wind_up_v0/optimize_northing.py | 3 +- 2 files changed, 91 insertions(+), 84 deletions(-) diff --git a/src/wind_up/northing.py b/src/wind_up/northing.py index 41e7564d..fd72239c 100644 --- a/src/wind_up/northing.py +++ b/src/wind_up/northing.py @@ -24,6 +24,7 @@ import logging import math from dataclasses import dataclass, replace +from functools import partial from typing import TYPE_CHECKING import numpy as np @@ -32,7 +33,7 @@ from wind_up.circular_math import circ_diff, circ_median if TYPE_CHECKING: - from collections.abc import Mapping + from collections.abc import Callable, Mapping import numpy.typing as npt @@ -61,13 +62,6 @@ _MAX_TRANSIENT_STEP_DEG = 10.0 -# A consensus needs a strict majority of the farm reporting. Below that the median is over an -# unrepresentative few, whose own veer moves the reference rather than the farm's. -def _farm_quorum(n_devices: int, *, floor: int) -> int: - """Return how many devices must report for their median to stand for the farm's consensus.""" - return max(floor, n_devices // 2 + 1) - - # The span either side of a changepoint at which ``min_step_deg`` applies unmodified. With less # record than this the level is veer-limited rather than sample-limited, so a bigger step is # needed to tell a recalibration from the wander. @@ -143,7 +137,7 @@ def anchoring_only(settings: NorthingSettings) -> NorthingSettings: finer is left to the second pass, which works against the farm consensus and estimates from the **raw** direction, so nothing is lost by deferring it. """ - return replace(against_reanalysis(settings), min_step_deg=ANCHORING_MIN_STEP_DEG) + return replace(settings, min_step_deg=ANCHORING_MIN_STEP_DEG) def against_reanalysis(settings: NorthingSettings) -> NorthingSettings: @@ -242,7 +236,7 @@ def veer_normalised( sector levels are measured on it rather than on ``residual``, so a large step cannot leak into the veer signature. Defaults to ``residual`` itself. """ - signature = sector_signature( + signature = _sector_signature( residual if de_stepped is None else de_stepped, reference_deg=reference_deg, sector_deg=sector_deg, @@ -254,7 +248,7 @@ def veer_normalised( return out -def sector_signature( +def _sector_signature( values_deg: npt.NDArray[np.float64], *, reference_deg: npt.NDArray[np.float64], @@ -472,96 +466,106 @@ def _persistence(offsets: list[float], *, durations: npt.NDArray[np.float64]) -> ) -def _prune_transient_steps( +def _prune_while( changepoints: list[pd.Timestamp], offsets: list[float], *, start: pd.Timestamp, - end: pd.Timestamp, residual: npt.NDArray[np.float64], index: pd.DatetimeIndex, - min_step_deg: float, - max_transient_step_deg: float, + worst: Callable[[list[pd.Timestamp], list[float]], int | None], ) -> tuple[list[pd.Timestamp], list[float]]: - """Iron out small excursions -- site veer wandering away and back, rather than a recalibration. + """Drop whichever changepoint ``worst`` names, re-estimating offsets, until it names none. - Repeatedly removes the least persistent changepoint while any **small** one fails to move the - long-run level by ``min_step_deg``, re-estimating the offsets after each merge. Steps larger - than ``max_transient_step_deg`` are never removed: a real recalibration is sometimes reversed - later, and its size is the evidence that it happened. + Offsets must be re-estimated after every merge: joining two segments changes the level of the + result, which can in turn change which of the survivors looks weakest. """ - while len(changepoints) > 0: - edges = [start, *changepoints, end] - durations = np.array([max((b - a).total_seconds(), 1.0) for a, b in itertools.pairwise(edges)], dtype=float) - persistence = _persistence(offsets, durations=durations) - steps = np.abs(circ_diff(np.array(offsets[1:]), np.array(offsets[:-1]))) - candidates = np.flatnonzero((steps < max_transient_step_deg) & (persistence < min_step_deg)) - if len(candidates) == 0: - break - weakest = int(candidates[np.argmin(persistence[candidates])]) - changepoints = [c for i, c in enumerate(changepoints) if i != weakest] + while changepoints: + drop = worst(changepoints, offsets) + if drop is None: + return changepoints, offsets + changepoints = [c for i, c in enumerate(changepoints) if i != drop] offsets = _segment_offsets(changepoints, start=start, residual=residual, index=index) return changepoints, offsets -def _required_step( +def _steps(offsets: list[float]) -> npt.NDArray[np.float64]: + """Return the size of the step at each changepoint, in degrees.""" + return np.abs(circ_diff(np.array(offsets[1:]), np.array(offsets[:-1]))) + + +def _worst_transient( changepoints: list[pd.Timestamp], + offsets: list[float], *, start: pd.Timestamp, end: pd.Timestamp, min_step_deg: float, max_transient_step_deg: float, - confident_segment: pd.Timedelta, -) -> npt.NDArray[np.float64]: - """Return the step size each changepoint must reach, given the record supporting it. +) -> int | None: + """Return the least persistent **small** changepoint -- site veer wandering away and back. - A segment's level is limited by site veer rather than by sampling noise, and veer averages out - no faster than ``1/sqrt(span)``. So with less than ``confident_segment`` either side the - required step grows accordingly, capped at ``max_transient_step_deg`` -- above which a step is - credible however little record sits around it. + Steps larger than ``max_transient_step_deg`` are never named: a real recalibration is + sometimes reversed later, and its size is the evidence that it happened. """ edges = [start, *changepoints, end] - spans = np.array([max((b - a) / confident_segment, 1e-9) for a, b in itertools.pairwise(edges)]) - support = np.minimum(spans[:-1], spans[1:]) - return np.clip(min_step_deg / np.sqrt(np.minimum(support, 1.0)), min_step_deg, max_transient_step_deg) + durations = np.array([max((b - a).total_seconds(), 1.0) for a, b in itertools.pairwise(edges)], dtype=float) + persistence = _persistence(offsets, durations=durations) + candidates = np.flatnonzero((_steps(offsets) < max_transient_step_deg) & (persistence < min_step_deg)) + if len(candidates) == 0: + return None + return int(candidates[np.argmin(persistence[candidates])]) -def _prune_small_steps( +def _worst_unsupported( changepoints: list[pd.Timestamp], offsets: list[float], *, start: pd.Timestamp, end: pd.Timestamp, - residual: npt.NDArray[np.float64], - index: pd.DatetimeIndex, min_step_deg: float, max_transient_step_deg: float, confident_segment: pd.Timedelta, -) -> tuple[list[pd.Timestamp], list[float]]: - """Drop changepoints whose step is too small for the record supporting them. +) -> int | None: + """Return the changepoint whose step falls furthest short of what its record can support. This is what makes ``min_step_deg`` mean what it says: a step smaller than it is never reported. Near the start or end of a record -- or squeezed between two other changepoints -- - more is required, because there is less data to tell a step from veer. Offsets are - re-estimated after each merge, since merging two segments changes the level of the result. + more is required, because there is less data with which to tell a step from veer. """ - while changepoints: - steps = np.abs(circ_diff(np.array(offsets[1:]), np.array(offsets[:-1]))) - required = _required_step( - changepoints, - start=start, - end=end, - min_step_deg=min_step_deg, - max_transient_step_deg=max_transient_step_deg, - confident_segment=confident_segment, - ) - shortfall = required - steps - weakest = int(np.argmax(shortfall)) - if shortfall[weakest] <= 0: - break - changepoints = [c for i, c in enumerate(changepoints) if i != weakest] - offsets = _segment_offsets(changepoints, start=start, residual=residual, index=index) - return changepoints, offsets + required = _required_step( + changepoints, + start=start, + end=end, + min_step_deg=min_step_deg, + max_transient_step_deg=max_transient_step_deg, + confident_segment=confident_segment, + ) + shortfall = required - _steps(offsets) + weakest = int(np.argmax(shortfall)) + return weakest if shortfall[weakest] > 0 else None + + +def _required_step( + changepoints: list[pd.Timestamp], + *, + start: pd.Timestamp, + end: pd.Timestamp, + min_step_deg: float, + max_transient_step_deg: float, + confident_segment: pd.Timedelta, +) -> npt.NDArray[np.float64]: + """Return the step size each changepoint must reach, given the record supporting it. + + A segment's level is limited by site veer rather than by sampling noise, and veer averages out + no faster than ``1/sqrt(span)``. So with less than ``confident_segment`` either side the + required step grows accordingly, capped at ``max_transient_step_deg`` -- above which a step is + credible however little record sits around it. + """ + edges = [start, *changepoints, end] + spans = np.array([max((b - a) / confident_segment, 1e-9) for a, b in itertools.pairwise(edges)]) + support = np.minimum(spans[:-1], spans[1:]) + return np.clip(min_step_deg / np.sqrt(np.minimum(support, 1.0)), min_step_deg, max_transient_step_deg) def estimate_north_table( @@ -663,26 +667,21 @@ def detect(searched: npt.NDArray[np.float64]) -> list[pd.Timestamp]: ) offsets = _segment_offsets(changepoints, start=start, residual=residual, index=index) - changepoints, offsets = _prune_transient_steps( - changepoints, - offsets, - start=start, - end=end, - residual=residual, - index=index, - min_step_deg=settings.min_step_deg, - max_transient_step_deg=settings.max_transient_step_deg, - ) - changepoints, offsets = _prune_small_steps( + bounds = {"start": start, "residual": residual, "index": index} + rule = { + "start": start, + "end": end, + "min_step_deg": settings.min_step_deg, + "max_transient_step_deg": settings.max_transient_step_deg, + } + # First iron out excursions, then drop what the record cannot support. Order matters: a step + # only looks unsupported once the excursion around it has gone. + changepoints, offsets = _prune_while(changepoints, offsets, **bounds, worst=partial(_worst_transient, **rule)) + changepoints, offsets = _prune_while( changepoints, offsets, - start=start, - end=end, - residual=residual, - index=index, - min_step_deg=settings.min_step_deg, - max_transient_step_deg=settings.max_transient_step_deg, - confident_segment=settings.confident_segment, + **bounds, + worst=partial(_worst_unsupported, **rule, confident_segment=settings.confident_segment), ) return _table([start, *changepoints], offsets) @@ -729,6 +728,13 @@ def _median_across(stack: npt.NDArray[np.float64], *, enough: npt.NDArray[np.boo return farm +# A consensus needs a strict majority of the farm reporting. Below that the median is over an +# unrepresentative few, whose own veer moves the reference rather than the farm's. +def _farm_quorum(n_devices: int, *, floor: int) -> int: + """Return how many devices must report for their median to stand for the farm's consensus.""" + return max(floor, n_devices // 2 + 1) + + def _farm_direction( northed: Mapping[str, npt.NDArray[np.float64]], *, diff --git a/src/wind_up_v0/optimize_northing.py b/src/wind_up_v0/optimize_northing.py index 7cad936e..e8e11ab6 100644 --- a/src/wind_up_v0/optimize_northing.py +++ b/src/wind_up_v0/optimize_northing.py @@ -22,6 +22,7 @@ NORTH_OFFSET_COL, NorthingSettings, against_reanalysis, + anchoring_only, apply_north_table, estimate_north_table, yaw_usable, @@ -249,7 +250,7 @@ def auto_northing_corrections( wf_df = wf_df.copy() reanalysis_wf_north_table = _north_wf_table( - wf_df, north_ref_wd_col=REANALYSIS_WD_COL, cfg=cfg, plot_cfg=plot_cfg, settings=against_reanalysis(settings) + wf_df, north_ref_wd_col=REANALYSIS_WD_COL, cfg=cfg, plot_cfg=plot_cfg, settings=anchoring_only(settings) ) if plot_cfg is not None: reanalysis_wf_north_table.to_csv(cfg.out_dir / "reanalysis_wf_north_table.csv") From 0539cb33874a4c99079f2c52f9f03d00ea420c2b Mon Sep 17 00:00:00 2001 From: aclerc Date: Thu, 3 Sep 2026 08:05:07 +0100 Subject: [PATCH 05/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- benchmarking/synthetic/sources/greenbyte.py | 183 +++++++++++++++++ .../synthetic/sources/test_greenbyte.py | 184 ++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 benchmarking/synthetic/sources/greenbyte.py create mode 100644 tests/benchmarking/synthetic/sources/test_greenbyte.py diff --git a/benchmarking/synthetic/sources/greenbyte.py b/benchmarking/synthetic/sources/greenbyte.py new file mode 100644 index 00000000..404e658d --- /dev/null +++ b/benchmarking/synthetic/sources/greenbyte.py @@ -0,0 +1,183 @@ +"""Greenbyte-exported open SCADA: the Kelmarsh and Penmanshiel wind farms. + +Two open datasets published on Zenodo by Cubico, exported from Greenbyte in a shared CSV layout +(nine comment lines, then a ``#``-prefixed header). Both are simpler than Hill of Towie -- six +and fourteen Senvion turbines against HoT's twenty-one -- which makes them a useful 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 one normalisation: Greenbyte reports availability as a **fraction** of the period, so it is +converted to seconds here to match :data:`~benchmarking.synthetic.sources.hill_of_towie.HOT_COLUMNS`. +Source-specific knowledge belongs in the source adapter, not in the methods. + +Datasets: + +* Kelmarsh -- https://zenodo.org/records/5841834 +* Penmanshiel -- https://zenodo.org/records/5946808 +""" + +from __future__ import annotations + +import io +import logging +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING +from zipfile import ZipFile + +import pandas as pd + +from benchmarking.synthetic.schema import ColumnSchema + +if TYPE_CHECKING: + from collections.abc import Sequence + +logger = logging.getLogger(__name__) + +TIMEBASE_S = 600 +# Greenbyte writes nine comment lines before the header, which is itself commented. +_HEADER_ROW = 9 +_TIMESTAMP = "# Date and time" + +# The source-native names this adapter keeps. Everything else in the 299-column export is dropped. +POWER = "Power (kW)" +NACELLE_POSITION = "Nacelle position (°)" +WIND_SPEED = "Wind speed (m/s)" +WIND_SPEED_SD = "Wind speed, Standard deviation (m/s)" +AVAILABILITY = "availability_s" +TURBINE = "TurbineName" + +GREENBYTE_COLUMNS = ColumnSchema( + turbine=TURBINE, + active_power=POWER, + wind_speed=WIND_SPEED, + wind_speed_sd=WIND_SPEED_SD, + gen_rpm="Generator RPM (RPM)", + availability=AVAILABILITY, + nacelle_position=NACELLE_POSITION, +) + + +@dataclass(frozen=True) +class GreenbyteFarm: + """A Zenodo-published Greenbyte export. + + :param name: short name; also the prefix of the published files and the plot title + :param record: the Zenodo record id, so an error can say where to fetch the data + :param years: the calendar years published for this farm + :param rated_power_kw: the turbines' rated power + """ + + name: str + record: str + years: tuple[int, ...] + rated_power_kw: float + + @property + def static_file(self) -> str: + """The per-turbine metadata CSV published alongside the SCADA.""" + return f"{self.name}_WT_static.csv" + + +# Both farms publish 2016-2021. Penmanshiel splits each year across two zips (WT01-10, WT11-15); +# the loader globs rather than naming files, so either layout works and so do the shorter names a +# manual download tends to leave behind. +KELMARSH = GreenbyteFarm(name="Kelmarsh", record="5841834", years=tuple(range(2016, 2022)), rated_power_kw=2050.0) +PENMANSHIEL = GreenbyteFarm(name="Penmanshiel", record="5946808", years=tuple(range(2016, 2022)), rated_power_kw=2050.0) + +FARMS = {farm.name.lower(): farm for farm in (KELMARSH, PENMANSHIEL)} + + +def get_data_dir() -> Path: + """Return the local cache directory for these datasets, creating it if needed.""" + root = Path(os.getenv("WIND_UP_BENCHMARKING_DATA_DIR", Path.home() / "temp" / "wind-up-benchmarking" / "data")) + path = root / "zenodo" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _turbine_name(member: str) -> str: + """``Turbine_Data_Kelmarsh_3_2017-...csv`` -> ``T03``; the farm's own numbering, zero-padded.""" + match = re.search(r"Turbine_Data_[A-Za-z]+_(\d+)_", member) + if match is None: + msg = f"cannot read a turbine number from {member!r}" + raise ValueError(msg) + return f"T{int(match.group(1)):02d}" + + +def load_greenbyte_metadata(farm: GreenbyteFarm, *, data_dir: Path | None = None) -> pd.DataFrame: + """Return per-turbine ``Name``, ``Latitude`` and ``Longitude`` for ``farm``. + + Names are normalised to ``T01``-style so they match the SCADA frame. + """ + path = (data_dir or get_data_dir()) / farm.static_file + static = pd.read_csv(path, encoding="utf-8-sig") + # Penmanshiel's CSV carries a trailing blank row, so rows without a turbine number are dropped + numbers = static["Title"].astype(str).str.extract(r"(\d+)$")[0] + keep = numbers.notna() + return pd.DataFrame( + { + "Name": [f"T{int(n):02d}" for n in numbers[keep]], + "Latitude": static.loc[keep, "Latitude"].astype(float).to_numpy(), + "Longitude": static.loc[keep, "Longitude"].astype(float).to_numpy(), + } + ) + + +def load_greenbyte_scada( + farm: GreenbyteFarm, + *, + years: Sequence[int], + data_dir: Path | None = None, + columns: Sequence[str] = (POWER, NACELLE_POSITION, WIND_SPEED, WIND_SPEED_SD), +) -> pd.DataFrame: + """Return long, timestamp-indexed SCADA for ``farm`` over ``years``. + + One row per turbine per 10-minute period, with :data:`TURBINE` naming the turbine and + availability converted from Greenbyte's fraction to seconds. + + :param years: calendar years to load; each must be published for this farm + :param data_dir: where the Zenodo zips are cached; defaults to :func:`get_data_dir` + :param columns: the source-native value columns to keep besides availability + :raises FileNotFoundError: if a year's zip has not been downloaded + """ + directory = data_dir or get_data_dir() + wanted = [_TIMESTAMP, *columns, "Time-based System Avail."] + frames = [] + for year in years: + if year not in farm.years: + msg = f"{farm.name} has no published SCADA for {year}; have {list(farm.years)}" + raise ValueError(msg) + paths = sorted(directory.glob(f"{farm.name}*SCADA*{year}*.zip")) + if not paths: + msg = ( + f"no {farm.name} {year} SCADA zip in {directory}. Fetch it from " + f"https://zenodo.org/records/{farm.record}." + ) + raise FileNotFoundError(msg) + for path in paths: + frames.extend(_read_zip(path, wanted=wanted)) + scada = pd.concat(frames).sort_index() + scada[AVAILABILITY] = scada.pop("Time-based System Avail.").astype(float) * TIMEBASE_S + return scada + + +def _read_zip(path: Path, *, wanted: Sequence[str]) -> list[pd.DataFrame]: + """Read every turbine's data file out of one published zip.""" + logger.info("reading %s", path.name) + frames = [] + with ZipFile(path) as archive: + for member in sorted(archive.namelist()): + if not member.startswith("Turbine_Data"): + continue + raw = pd.read_csv( + io.BytesIO(archive.read(member)), + skiprows=_HEADER_ROW, + usecols=lambda c, wanted=tuple(wanted): c in wanted, + parse_dates=[_TIMESTAMP], + ) + raw[TURBINE] = _turbine_name(member) + frames.append(raw.set_index(_TIMESTAMP).tz_localize("UTC")) + return frames diff --git a/tests/benchmarking/synthetic/sources/test_greenbyte.py b/tests/benchmarking/synthetic/sources/test_greenbyte.py new file mode 100644 index 00000000..1468a51c --- /dev/null +++ b/tests/benchmarking/synthetic/sources/test_greenbyte.py @@ -0,0 +1,184 @@ +"""Tests for the Greenbyte source adapter (Kelmarsh and Penmanshiel). + +The published zips are hundreds of megabytes, so these build the export layout instead: nine +comment lines, a ``#``-prefixed header, then rows. What is worth pinning is the parsing contract +and the two source quirks the adapter exists to absorb -- availability published as a fraction, +and a trailing blank row in Penmanshiel's metadata. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from zipfile import ZipFile + +import pandas as pd +import pytest + +from benchmarking.synthetic.sources.greenbyte import ( + AVAILABILITY, + GREENBYTE_COLUMNS, + KELMARSH, + NACELLE_POSITION, + PENMANSHIEL, + POWER, + TIMEBASE_S, + TURBINE, + load_greenbyte_metadata, + load_greenbyte_scada, +) + +if TYPE_CHECKING: + from pathlib import Path + +_PREAMBLE = "\n".join(f"# comment line {i}" for i in range(9)) +_HEADER = ( + "# Date and time,Wind speed (m/s)," + '"Wind speed, Standard deviation (m/s)",Power (kW),' + "Nacelle position (°),Time-based System Avail.,Generator RPM (RPM)" +) + + +def _turbine_csv(*, rows: int, start: str, power: float, nacelle: float, availability: float) -> str: + """One turbine's data file in the published layout.""" + index = pd.date_range(start=start, periods=rows, freq=f"{TIMEBASE_S}s") + body = "\n".join(f"{ts:%Y-%m-%d %H:%M:%S},8.0,0.5,{power},{nacelle},{availability},1500.0" for ts in index) + return f"{_PREAMBLE}\n{_HEADER}\n{body}\n" + + +def _write_zip(path: Path, *, farm: str, turbines: range, year: int, rows: int = 6, availability: float = 1.0) -> None: + """A published SCADA zip: one data file and one status file per turbine.""" + with ZipFile(path, "w") as archive: + for number in turbines: + stem = f"{farm}_{number}_{year}-01-01_-_{year + 1}-01-01_{200 + number}" + archive.writestr( + f"Turbine_Data_{stem}.csv", + _turbine_csv( + rows=rows, + start=f"{year}-01-01", + power=100.0 * number, + nacelle=10.0 * number, + availability=availability, + ), + ) + # status files sit alongside the data and must be ignored + archive.writestr(f"Status_{stem}.csv", "irrelevant\n") + + +@pytest.fixture +def kelmarsh_dir(tmp_path: Path) -> Path: + _write_zip(tmp_path / "Kelmarsh_SCADA_2017.zip", farm="Kelmarsh", turbines=range(1, 4), year=2017) + return tmp_path + + +class TestLoadScada: + def test_reads_every_turbine_past_the_comment_preamble(self, kelmarsh_dir: Path) -> None: + scada = load_greenbyte_scada(KELMARSH, years=[2017], data_dir=kelmarsh_dir) + assert sorted(scada[TURBINE].unique()) == ["T01", "T02", "T03"] + assert len(scada) == 18 # 3 turbines x 6 rows + assert not scada.columns.str.startswith("#").any() + + def test_turbine_numbers_are_zero_padded_to_match_the_metadata(self, kelmarsh_dir: Path) -> None: + scada = load_greenbyte_scada(KELMARSH, years=[2017], data_dir=kelmarsh_dir) + assert "T03" in set(scada[TURBINE]) + assert "T3" not in set(scada[TURBINE]) + + def test_the_index_is_utc_timestamps(self, kelmarsh_dir: Path) -> None: + scada = load_greenbyte_scada(KELMARSH, years=[2017], data_dir=kelmarsh_dir) + assert isinstance(scada.index, pd.DatetimeIndex) + assert str(scada.index.tz) == "UTC" + assert scada.index.min() == pd.Timestamp("2017-01-01", tz="UTC") + + def test_availability_is_converted_from_a_fraction_to_seconds(self, tmp_path: Path) -> None: + """Greenbyte publishes a fraction of the period; the rest of the layer expects seconds.""" + _write_zip( + tmp_path / "Kelmarsh_SCADA_2017.zip", + farm="Kelmarsh", + turbines=range(1, 2), + year=2017, + availability=0.5, + ) + scada = load_greenbyte_scada(KELMARSH, years=[2017], data_dir=tmp_path) + assert (scada[AVAILABILITY] == TIMEBASE_S * 0.5).all() + assert "Time-based System Avail." not in scada.columns + + def test_status_files_are_ignored(self, kelmarsh_dir: Path) -> None: + scada = load_greenbyte_scada(KELMARSH, years=[2017], data_dir=kelmarsh_dir) + assert scada[POWER].notna().all() + + def test_several_years_are_concatenated_in_time_order(self, kelmarsh_dir: Path) -> None: + _write_zip(kelmarsh_dir / "Kelmarsh_SCADA_2018.zip", farm="Kelmarsh", turbines=range(1, 4), year=2018) + scada = load_greenbyte_scada(KELMARSH, years=[2017, 2018], data_dir=kelmarsh_dir) + assert scada.index.is_monotonic_increasing + assert scada.index.min().year == 2017 + assert scada.index.max().year == 2018 + + def test_a_year_split_across_two_zips_is_read_whole(self, tmp_path: Path) -> None: + """Penmanshiel publishes WT01-10 and WT11-15 separately; both belong to one year.""" + _write_zip( + tmp_path / "Penmanshiel_SCADA_2017_WT01-10.zip", farm="Penmanshiel", turbines=range(1, 11), year=2017 + ) + _write_zip( + tmp_path / "Penmanshiel_SCADA_2017_WT11-15.zip", farm="Penmanshiel", turbines=range(11, 16), year=2017 + ) + scada = load_greenbyte_scada(PENMANSHIEL, years=[2017], data_dir=tmp_path) + assert scada[TURBINE].nunique() == 15 + + def test_the_columns_the_schema_names_are_present(self, kelmarsh_dir: Path) -> None: + scada = load_greenbyte_scada(KELMARSH, years=[2017], data_dir=kelmarsh_dir) + for role in ("turbine", "active_power", "wind_speed", "wind_speed_sd", "availability", "nacelle_position"): + assert getattr(GREENBYTE_COLUMNS, role) in scada.columns, role + + def test_values_land_in_the_right_columns(self, kelmarsh_dir: Path) -> None: + scada = load_greenbyte_scada(KELMARSH, years=[2017], data_dir=kelmarsh_dir) + t02 = scada[scada[TURBINE] == "T02"] + assert (t02[POWER] == 200.0).all() + assert (t02[NACELLE_POSITION] == 20.0).all() + + +class TestMissingData: + def test_an_unpublished_year_raises_naming_what_is_available(self, kelmarsh_dir: Path) -> None: + with pytest.raises(ValueError, match="no published SCADA for 2030"): + load_greenbyte_scada(KELMARSH, years=[2030], data_dir=kelmarsh_dir) + + def test_a_missing_download_raises_pointing_at_the_zenodo_record(self, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError, match=KELMARSH.record): + load_greenbyte_scada(KELMARSH, years=[2017], data_dir=tmp_path) + + +class TestMetadata: + @staticmethod + def _write_static(path: Path, *, farm: str, rows: int, trailing_blank: bool) -> None: + header = ( + "Wind Farm,Title,Alternative Title,Identity,Manufacturer,Model,Rated power (kW)," + "Hub Height (m),Rotor Diameter (m),Latitude,Longitude,Elevation (m),Country," + "Commercial Operations Date" + ) + lines = [header] + lines.extend( + f"{farm},{farm} {n},T{n:02d},X,Senvion,MM92,2050,78.5,92,5{n}.1,-0.9{n},145,UK,15/04/2016" + for n in range(1, rows + 1) + ) + if trailing_blank: + lines.append(",,,,,,,,,,,,,") + path.write_text("\n".join(lines) + "\n", encoding="utf-8-sig") + + def test_names_are_normalised_to_match_the_scada(self, tmp_path: Path) -> None: + self._write_static(tmp_path / KELMARSH.static_file, farm="Kelmarsh", rows=6, trailing_blank=False) + metadata = load_greenbyte_metadata(KELMARSH, data_dir=tmp_path) + assert list(metadata["Name"]) == [f"T{n:02d}" for n in range(1, 7)] + assert metadata["Latitude"].dtype == float + + def test_a_trailing_blank_row_is_dropped(self, tmp_path: Path) -> None: + """Penmanshiel's published CSV ends with an all-empty row.""" + self._write_static(tmp_path / PENMANSHIEL.static_file, farm="Penmanshiel", rows=14, trailing_blank=True) + metadata = load_greenbyte_metadata(PENMANSHIEL, data_dir=tmp_path) + assert len(metadata) == 14 + assert metadata["Name"].is_unique + + +class TestFarmDefinitions: + @pytest.mark.parametrize("farm", [KELMARSH, PENMANSHIEL]) + def test_the_published_years_are_declared(self, farm: object) -> None: + assert farm.years == tuple(range(2016, 2022)) + assert farm.static_file.endswith("_WT_static.csv") + assert farm.record.isdigit() From 97c86bbeeee65e976354d13614507602bb6bee7e Mon Sep 17 00:00:00 2001 From: aclerc Date: Thu, 3 Sep 2026 08:29:35 +0100 Subject: [PATCH 06/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- .../baselines/study_northing_subsets.py | 284 ++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 benchmarking/baselines/study_northing_subsets.py diff --git a/benchmarking/baselines/study_northing_subsets.py b/benchmarking/baselines/study_northing_subsets.py new file mode 100644 index 00000000..de807fb9 --- /dev/null +++ b/benchmarking/baselines/study_northing_subsets.py @@ -0,0 +1,284 @@ +"""Consistency of the northing method across subsets of a farm and of its record. + +A method that reports different corrections depending on which turbines or which years it was +handed is not trustworthy, however good its answer on the full record. + +This runs the shipped two-pass northing over a grid of turbine groups and time windows on real Hill of Towie data, then +reports where a subset finds a changepoint the full run does not. + +The subsets are declared in :data:`WINDOWS` and :data:`GROUPS` so the exercise re-runs unchanged +after any change to the estimator, and two runs can be compared directly. + +Run it:: + + uv run python -m benchmarking.baselines.study_northing_subsets + +The first run builds a compact input frame (one row per turbine per record, yaw + reanalysis +direction + power + availability) year by year and caches it, which takes some minutes and needs +the Hill of Towie SCADA downloaded. Later runs reuse the cache. Outputs land under +``WIND_UP_BENCHMARKING_OUTPUT_DIR``/``northing_subsets``/. +""" + +from __future__ import annotations + +import logging +import os +import time +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np +import pandas as pd +import yaml + +from benchmarking.baselines.hot_context import NORTHING_YAML, build_hot_v0_context +from benchmarking.synthetic import HOT_COLUMNS +from benchmarking.synthetic.sources.hill_of_towie import load_hot_scada +from wind_up.circular_math import circ_diff +from wind_up.northing import DEFAULT_NORTHING, north_farm, yaw_usable + +if TYPE_CHECKING: + from collections.abc import Sequence + +logger = logging.getLogger(__name__) + +TIMEBASE_S = 600.0 +RATED_KW = 2300.0 +YEARS = range(2016, 2025) + +ALL = tuple(f"T{n:02d}" for n in range(1, 22)) +WEST = tuple(f"T{n:02d}" for n in range(1, 16)) +EAST = tuple(f"T{n:02d}" for n in range(16, 22)) +GROUPS = {"all": ALL, "west": WEST, "east": EAST} + + +def _window(start: str, end: str) -> tuple[pd.Timestamp, pd.Timestamp]: + return pd.Timestamp(start, tz="UTC"), pd.Timestamp(end, tz="UTC") + + +# Spanning three months to nine years, straddling the known recalibrations (spring/summer 2017) +# and the known farm outages (November 2019, June 2020), and placing those events at the start, +# middle and end of a window so an edge effect has somewhere to show. +WINDOWS: dict[str, tuple[pd.Timestamp, pd.Timestamp]] = { + "full_2016_2024": _window("2016-01-01", "2025-01-01"), + **{f"year_{y}": _window(f"{y}-01-01", f"{y + 1}-01-01") for y in YEARS}, + **{f"2y_{y}": _window(f"{y}-01-01", f"{y + 2}-01-01") for y in range(2016, 2024)}, + **{f"3y_{y}": _window(f"{y}-01-01", f"{y + 3}-01-01") for y in (2016, 2018, 2020, 2022)}, + "half_2017H1": _window("2017-01-01", "2017-07-01"), + "half_2017H2": _window("2017-07-01", "2018-01-01"), + "half_2019H2": _window("2019-07-01", "2020-01-01"), + "half_2020H1": _window("2020-01-01", "2020-07-01"), + "q_2017Q2_recals": _window("2017-04-01", "2017-07-01"), + "q_2017Q3": _window("2017-07-01", "2017-10-01"), + "q_2019Q4_outage": _window("2019-10-01", "2020-01-01"), + "q_2020Q2_outage": _window("2020-04-01", "2020-07-01"), + "q_2018Q4_edge": _window("2018-10-01", "2019-01-01"), + "edge_after_t16_recal": _window("2016-06-01", "2017-06-01"), + "edge_after_t05_recal": _window("2017-06-01", "2018-05-01"), +} + +REFERENCE_CASE = "all__full_2016_2024" + + +def default_output_root() -> Path: + """Return the directory this driver writes under (``WIND_UP_BENCHMARKING_OUTPUT_DIR`` overrides).""" + root = Path(os.getenv("WIND_UP_BENCHMARKING_OUTPUT_DIR", Path.home() / "temp" / "wind-up-benchmarking")) + return root / "northing_subsets" + + +def build_inputs(out_dir: Path, *, years: Sequence[int] = tuple(YEARS)) -> pd.DataFrame: + """Build (or reuse) the compact northing-input frame, caching one parquet per year. + + Loading nine years of 21-turbine SCADA at once needs far more memory than the northing itself, + so each year is reduced to the handful of columns the estimator reads and cached. + """ + cache = out_dir / "years" + cache.mkdir(parents=True, exist_ok=True) + era5 = build_hot_v0_context(wtg_names=list(ALL)).reanalysis_datasets[0].data["wind_direction_100m"] + frames = [] + for year in years: + cached = cache / f"{year}.parquet" + if cached.exists(): + frames.append(pd.read_parquet(cached)) + continue + scada, _ = load_hot_scada( + start_dt=pd.Timestamp(f"{year}-01-01", tz="UTC"), + end_dt_excl=pd.Timestamp(f"{year + 1}-01-01", tz="UTC"), + wtg_numbers=list(range(1, 22)), + wtg_names=list(ALL), + ) + index = pd.DatetimeIndex(scada.index) + # the SCADA index repeats each timestamp once per turbine, so carry ERA5 onto the unique + # timestamps and let the lookup broadcast it back + unique = pd.DatetimeIndex(index.unique()).sort_values() + wd = era5.reindex(era5.index.union(unique)).ffill(limit=6).reindex(unique).reindex(index) + year_frame = pd.DataFrame( + { + "turbine": scada[HOT_COLUMNS.turbine].astype(str).to_numpy(), + "timestamp": index, + "yaw_deg": scada[HOT_COLUMNS.nacelle_position].to_numpy(np.float32), + "power_kw": scada[HOT_COLUMNS.active_power].to_numpy(np.float32), + "availability_s": scada[HOT_COLUMNS.availability].to_numpy(np.float32), + "era5_wd_deg": wd.to_numpy(np.float32), + } + ) + year_frame.to_parquet(cached, index=False, compression="zstd") + frames.append(year_frame) + logger.info("built %d: %d rows", year, len(year_frame)) + del scada, year_frame + frame = pd.concat(frames, ignore_index=True) + frame["turbine"] = frame["turbine"].astype("category") + return frame.sort_values("timestamp") + + +def run_case(frame: pd.DataFrame, turbines: Sequence[str], start: pd.Timestamp, end: pd.Timestamp) -> pd.DataFrame: + """North one (turbines, window) and return its changepoints as ``turbine``/``date``/``step_deg``.""" + rows = frame[(frame["turbine"].isin(turbines)) & (frame["timestamp"] >= start) & (frame["timestamp"] < end)] + if rows.empty: + return pd.DataFrame(columns=["turbine", "date", "step_deg"]) + index = pd.DatetimeIndex(sorted(rows["timestamp"].unique())) + direction: dict[str, np.ndarray] = {} + usable: dict[str, np.ndarray] = {} + reanalysis = np.full(len(index), np.nan) + for turbine in sorted(rows["turbine"].unique()): + one = rows[rows["turbine"] == turbine].drop_duplicates("timestamp").set_index("timestamp").reindex(index) + wd = one["era5_wd_deg"].to_numpy(dtype=float) + reanalysis = np.where(np.isfinite(reanalysis), reanalysis, wd) + direction[str(turbine)] = one["yaw_deg"].to_numpy(dtype=float) + usable[str(turbine)] = yaw_usable( + power=one["power_kw"].to_numpy(dtype=float), + downtime_s=TIMEBASE_S - np.nan_to_num(one["availability_s"].to_numpy(dtype=float), nan=0.0), + reference_deg=wd, + rated_power=RATED_KW, + timebase_s=TIMEBASE_S, + ) + if len(direction) < 3: # noqa: PLR2004 - north_farm needs a farm + return pd.DataFrame(columns=["turbine", "date", "step_deg"]) + tables = north_farm( + index, direction_deg=direction, usable=usable, reanalysis_deg=reanalysis, settings=DEFAULT_NORTHING + ) + found: list[dict[str, object]] = [] + for name, table in sorted(tables.items()): + offsets = table["north_offset"].to_numpy(dtype=float) + found.extend( + { + "turbine": name, + "date": table["timestamp"].iloc[i], + "step_deg": round(float(circ_diff(offsets[i], offsets[i - 1])), 2), + } + for i in range(1, len(table)) + ) + return pd.DataFrame(found, columns=["turbine", "date", "step_deg"]) + + +def _match(expected: pd.DataFrame, found: pd.DataFrame, *, days: float = 5.0) -> int: + """How many of ``expected`` have a counterpart in ``found`` within ``days``, matched one to one.""" + used: set = set() + matched = 0 + for row in expected.itertuples(): + candidates = found[ + (found["turbine"] == row.turbine) & ((found["date"] - row.date).abs() <= pd.Timedelta(days=days)) + ] + candidates = candidates[~candidates.index.isin(used)] + if len(candidates): + used.add((candidates["date"] - row.date).abs().idxmin()) + matched += 1 + return matched + + +def run_study(*, out_root: Path | None = None) -> tuple[pd.DataFrame, pd.DataFrame]: + """Run every subset and compare each with the full run over the same window. + + :return: ``(changepoints, consistency)`` -- one row per found changepoint, and one row per case + with how many it found, matched, invented (``extra``) and lost (``missing``) + """ + out_dir = out_root or default_output_root() + out_dir.mkdir(parents=True, exist_ok=True) + frame = build_inputs(out_dir) + logger.info("frame: %d rows, %s..%s", len(frame), frame["timestamp"].min(), frame["timestamp"].max()) + + cases = [ + {"case": f"{group}__{name}", "group": group, "window": name, "turbines": turbines, "span": span} + for group, turbines in GROUPS.items() + for name, span in WINDOWS.items() + ] + changepoints, summary = [], [] + for n, case in enumerate(cases, start=1): + began = time.perf_counter() + found = run_case(frame, case["turbines"], *case["span"]) + found.insert(0, "case", case["case"]) + changepoints.append(found) + summary.append( + { + "case": case["case"], + "group": case["group"], + "window": case["window"], + "start": case["span"][0].date(), + "end": case["span"][1].date(), + "n_found": len(found), + "seconds": round(time.perf_counter() - began, 1), + } + ) + logger.info("[%3d/%d] %-32s %4d cp %6.1fs", n, len(cases), case["case"], len(found), summary[-1]["seconds"]) + + found_all = pd.concat(changepoints, ignore_index=True) + reference = found_all[found_all["case"] == REFERENCE_CASE] + rows: list[dict[str, object]] = [] + for entry in summary: + if entry["case"] == REFERENCE_CASE: + continue + case_found = found_all[found_all["case"] == entry["case"]] + start, end = pd.Timestamp(entry["start"], tz="UTC"), pd.Timestamp(entry["end"], tz="UTC") + expected = reference[ + (reference["date"] >= start) + & (reference["date"] < end) + & (reference["turbine"].isin(GROUPS[entry["group"]])) + ] + matched = _match(expected, case_found) + rows.append( + { + **entry, + "n_expected": len(expected), + "matched": matched, + "extra": len(case_found) - matched, + "missing": len(expected) - matched, + } + ) + consistency = pd.DataFrame(rows).sort_values("extra", ascending=False) + found_all.to_csv(out_dir / "changepoints.csv", index=False) + consistency.to_csv(out_dir / "consistency.csv", index=False) + return found_all, consistency + + +def v0_published_changepoints() -> pd.DataFrame: + """Return v0's published Hill of Towie changepoints, to compare a run against.""" + published = pd.DataFrame( + [(str(a), pd.Timestamp(b, tz="UTC"), float(c)) for a, b, c in yaml.safe_load(NORTHING_YAML.read_text())], + columns=["turbine", "date", "north_offset"], + ).sort_values(["turbine", "date"]) + rows: list[dict[str, object]] = [] + for turbine, group in published.groupby("turbine"): + offsets = group["north_offset"].to_numpy() + rows.extend( + { + "turbine": turbine, + "date": group["date"].iloc[i], + "step_deg": round(float(circ_diff(offsets[i], offsets[i - 1])), 2), + } + for i in range(1, len(group)) + ) + return pd.DataFrame(rows) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + all_found, how_consistent = run_study() + full = all_found[all_found["case"] == REFERENCE_CASE] + v0 = v0_published_changepoints() + print(f"\nfull run: {len(full)} changepoints; v0's published table: {len(v0)}") # noqa: T201 + print( # noqa: T201 + f"cases inventing changepoints the full run does not see: " + f"{int((how_consistent['extra'] > 0).sum())}/{len(how_consistent)}; " + f"total extra {int(how_consistent['extra'].sum())}, total missing {int(how_consistent['missing'].sum())}" + ) + print(how_consistent.head(15).to_string(index=False)) # noqa: T201 From eeb4a1970f33450086e0b189fbb3b249046c3001 Mon Sep 17 00:00:00 2001 From: aclerc Date: Thu, 3 Sep 2026 12:06:36 +0100 Subject: [PATCH 07/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- docs/v1/findings_campaigns.md | 73 ++++++++++++++++++++++++++++++++++ src/wind_up/northing.py | 56 +++++++++++++++++++++----- tests/wind_up/test_northing.py | 53 ++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 10 deletions(-) diff --git a/docs/v1/findings_campaigns.md b/docs/v1/findings_campaigns.md index ee761bc9..7d23df0e 100644 --- a/docs/v1/findings_campaigns.md +++ b/docs/v1/findings_campaigns.md @@ -12,6 +12,79 @@ Keep entries reproducible: name the driver and the exact configuration, not just --- +## CF8 — Veer normalisation was being defeated by its own de-stepping: measuring the sector signature around a *speculative* split removes the very veer it should describe, so the split survives. Measuring it on the normalised residual instead cut the subset study's spurious changepoints 32 → 27, left every genuine one, and ran 34% faster + +*2026-09-03. Reproduce: `uv run python -m benchmarking.baselines.study_northing_subsets` (99 cases, +Hill of Towie 2016–2024), before and after the change to `estimate_north_table`. Synthetic +counterpart: `tests/wind_up/test_northing.py::TestVeerNormalisation`.* + +**Observed.** `veer_normalised` works exactly as designed — on a residual with 4° of +direction-dependent veer it takes the sector spread from 10–13° to **0.00°** and the day-to-day +wander of the daily level from 4.04° to 1.19°, the no-veer baseline. And it changed **nothing**: the +same changepoints were found with it on, off, and at every `veer_sector_deg` from 45° down to 10° +(false-positive counts 33/31/31/31/31). + +**Root cause.** The sector signature is measured on a residual de-stepped by the first detection +pass, so a real recalibration cannot leak into it. But under veer that pass *over-detects* — it hits +the `max_k` cap. De-stepping then removes each spurious segment's level, and those levels **are** the +veer signature. The signature is measured on a residual that no longer carries the veer, subtracts +almost nothing, and the second pass re-finds the same splits. The false positives immunise +themselves against the mechanism built to remove them. + +**Fix.** Search the veer-normalised residual first, with no step structure assumed, then re-measure +the signature around only the *confident* steps that search found (`_confident_steps`, +`VEER_SIGNATURE_MIN_STEP_DEG = 10.0`). Same number of searches as before. A genuine recalibration +still dominates the first search, so it is still de-stepped; a speculative split is not. + +**Evidence.** On the 99-case subset study: `extra` 32 → **27**, `matched` 211 → **211**, `missing` +8 → 8, runtime 820 s → **541 s**. The reference case (`all__full_2016_2024`, 19 changepoints) is +**byte-identical**. Every one of the 97 other cases is unchanged; the entire improvement is +`west__year_2021`, where **T11** goes from a self-cancelling cluster of six +(−12.6, +10.8, −10.4, +11.2, +8.1, −4.4) to a single 3.95° step. That is the window-dependence R1 +recorded as unresolved and left as genuine ambiguity — it was this bug. + +**Implication.** Veer normalisation is load-bearing and should be kept, not dropped: its apparent +weakness was this defeat, not the mechanism. Its effect is only visible once the de-stepping stops +hiding it. + +--- + +## CF7 — A turbine is part of the farm consensus it is northed against, which pins pass 2 to pass 1 on small odd farms; leave-one-out fixes that and detects much smaller steps, but costs more spurious ones, so it is **not** adopted + +*2026-09-03. Reproduce: SMARTEOLE example run with `optimize_northing_corrections=True`; synthetic +sweep in the session scratch; `study_northing_subsets` for the real-data cost. The regression test is +`test_pass_two_refines_every_device_on_an_odd_sized_farm`, **xfail(strict)** — it documents the +defect rather than a pending fix.* + +**Observed.** On SMARTEOLE (7 turbines, 3 months) the discovered north table is **byte-identical to +the pass-1 table on all seven turbines**, though pass 2's reference differs from reanalysis by 8.8° +mean absolute over the same rows. Pass 2 is an exact no-op. + +**Root cause.** `_farm_direction` takes a per-timestamp circular median across devices. With an odd +count the median **is** one of the devices, so wherever it is device *j*'s own reading the residual +is `raw_j − (raw_j + off_j)` — algebra, not measurement, identical on every such row. That point mass +(~10–15% of the sample) sits at the centre of the distribution and straddles the 50th percentile +(below 0.42–0.48, atom 0.10–0.15), so the median snaps to it and the other ~88% of genuine +measurements cannot move the answer. + +**Leave-one-out was built, measured and reverted.** It removes the atom entirely and is much better +at what pass 2 is *for*: detection floor ~4° against ~8°, and step **sizing** 0.3–0.4° error against +1.6–3.2° — self-inclusion attenuates the step because a stepping device drags the consensus with it. +The user's alternative, averaging the three values nearest the median, removes the atom but keeps the +attenuation, so it tracks the incumbent at every farm size tried. But on the 99-case study LOO scored +`extra` 32 → **42** for one recovered changepoint, and it needs a 4-device minimum farm. Not adopted: +the cost is real and the benefit is largest exactly where farms are smallest. + +**Scale.** The pin is severe only on small odd farms. Hill of Towie's 21 turbines dilute the atom to +3.3%, where leave-one-out moves offsets by 0.15° mean / 0.21° max and changes no changepoint; an even +count interpolates between two members and forms no atom at all. + +**Left open.** Whether to revisit LOO now that **CF8** removes most of its false-positive cost — with +the de-stepping fixed, its synthetic penalty falls to 1 spurious against 0 while it keeps +3 +detections and 4x better sizing. Not re-measured on the 99-case study. + +--- + ## CF6 — Honouring the declared `candidate_references` moves the prepost farm number 4x closer to truth (+0.148% → +0.039%), but *per-turbine* accuracy is marginally worse: the farm gain is cancellation, not better estimates *2026-09-02. Reproduce: `uv run python -m benchmarking.campaigns.placebo`, Hill of Towie, both diff --git a/src/wind_up/northing.py b/src/wind_up/northing.py index fd72239c..d6ba9b26 100644 --- a/src/wind_up/northing.py +++ b/src/wind_up/northing.py @@ -120,6 +120,11 @@ class NorthingSettings: # direction-dependent bias moves every turbine together by up to ~20 degrees during a spell of # unusual wind, so the bar sits above that. ANCHORING_MIN_STEP_DEG = 30.0 +# Only a step this large is taken out of the residual before the veer signature is measured. The +# de-stepping exists so a real recalibration cannot leak into the signature, but a speculative +# split takes the sector level with it -- and the signature is then measured on a residual that no +# longer carries the veer it is meant to describe. See :func:`_confident_steps`. +VEER_SIGNATURE_MIN_STEP_DEG = 10.0 DEFAULT_NORTHING = NorthingSettings() @@ -210,6 +215,26 @@ def _de_stepped( return out +def _confident_steps( + changepoints: list[pd.Timestamp], + *, + start: pd.Timestamp, + residual: npt.NDArray[np.float64], + index: pd.DatetimeIndex, + min_step_deg: float = VEER_SIGNATURE_MIN_STEP_DEG, +) -> list[pd.Timestamp]: + """Return the changepoints whose step is large enough to be a real recalibration. + + What the veer signature may be measured around. A search over a strongly veering residual + proposes splits that are the veer itself; de-stepping those would remove the signature. + """ + if not changepoints: + return [] + offsets = _segment_offsets(changepoints, start=start, residual=residual, index=index) + steps = _steps(offsets) + return [when for when, step in zip(changepoints, steps, strict=True) if step >= min_step_deg] + + def veer_normalised( residual: npt.NDArray[np.float64], *, @@ -651,19 +676,30 @@ def detect(searched: npt.NDArray[np.float64]) -> list[pd.Timestamp]: ) return found - changepoints = detect(residual) - if settings.veer_sector_deg is not None: - # Search again in the veer-normalised residual, so a shift in the direction mix cannot look - # like a step. The first pass exists only to take the step structure out of the way while - # the veer signature is measured; offsets come from the raw residual either way, so the - # correction stays absolute. - changepoints = detect( - veer_normalised( + if settings.veer_sector_deg is None: + changepoints = detect(residual) + else: + + def normalised(de_stepped: npt.NDArray[np.float64] | None) -> npt.NDArray[np.float64]: + return veer_normalised( residual, reference_deg=reference, - sector_deg=settings.veer_sector_deg, - de_stepped=_de_stepped(residual, index=index, edges=[start, *changepoints, end]), + sector_deg=settings.veer_sector_deg, # type: ignore[arg-type] + de_stepped=de_stepped, ) + + # Search in the veer-normalised residual, so a shift in the direction mix cannot look like + # a step. The signature is measured twice: first assuming no step structure, then around + # the confident steps that search found. Measuring it around a *speculative* split instead + # would remove the sector level along with the split, leaving the veer in place and the + # split with it. Offsets come from the raw residual either way, so the correction stays + # absolute. + provisional = detect(normalised(None)) + confident = _confident_steps(provisional, start=start, residual=residual, index=index) + changepoints = ( + detect(normalised(_de_stepped(residual, index=index, edges=[start, *confident, end]))) + if confident + else provisional ) offsets = _segment_offsets(changepoints, start=start, residual=residual, index=index) diff --git a/tests/wind_up/test_northing.py b/tests/wind_up/test_northing.py index cbe098a4..f929d117 100644 --- a/tests/wind_up/test_northing.py +++ b/tests/wind_up/test_northing.py @@ -364,6 +364,39 @@ def test_pass_two_beats_reanalysis_alone(self) -> None: circ_diff(one_pass["north_offset"].iloc[0], truth) ) + @pytest.mark.xfail( + reason="a device is part of the consensus it is northed against; see findings_campaigns.md CF7", + strict=True, + ) + def test_pass_two_refines_every_device_on_an_odd_sized_farm(self) -> None: + """A device may not be part of the consensus it is northed against. + + With an odd device count the per-timestamp median *is* one of the devices, so a device + that is its own reference scores an exact ``-offset`` residual on those rows. Those rows + are algebra rather than measurement, and they mass on one value at the centre of the + distribution, which pins the median to whatever pass 1 already said. + """ + index = _index() + names = ("T01", "T02", "T03", "T04", "T05") + offsets = {name: [("2017-01-01", 20.0)] for name in names} + reported, reference = self._farm(index, offsets) + rng = np.random.default_rng(11) + reanalysis = (reference + rng.normal(0.0, 25.0, size=len(index))) % 360.0 + usable = {name: _all_usable(index) for name in names} + + two_pass = north_farm(index, direction_deg=reported, usable=usable, reanalysis_deg=reanalysis) + + one_pass_errors, two_pass_errors = [], [] + for name in names: + one = estimate_north_table(index, reported[name], reference_deg=reanalysis, usable=usable[name]) + first, second = one["north_offset"].iloc[0], two_pass[name]["north_offset"].iloc[0] + assert second != pytest.approx(first, abs=1e-9), f"{name}: pass 2 merely repeated pass 1" + one_pass_errors.append(abs(circ_diff(first, 20.0))) + two_pass_errors.append(abs(circ_diff(second, 20.0))) + # a device pass 1 happened to get right can still move slightly the wrong way; the farm is + # what has to improve + assert np.mean(two_pass_errors) < np.mean(one_pass_errors) + def test_raises_when_too_few_devices_for_a_farm_reference(self) -> None: index = _index(days=30) offsets = {"T01": [("2017-01-01", 0.0)], "T02": [("2017-01-01", 5.0)]} @@ -463,6 +496,26 @@ def test_a_shifting_direction_mix_no_longer_reads_as_a_step(self) -> None: assert len(table) == 1, f"veer mistaken for a step: {table}" + @pytest.mark.parametrize("seed", [0, 1, 2]) + @pytest.mark.parametrize("veer_amplitude", [6.0, 8.0]) + def test_veer_strong_enough_to_over_detect_is_still_absorbed(self, veer_amplitude: float, seed: int) -> None: + """Smoothly direction-dependent veer, no true step: nothing may be found. + + The sector levels are measured on a residual de-stepped by the first detection pass. Veer + this strong makes that pass split the record, and de-stepping those splits takes the very + sector levels the signature is meant to capture -- so the splits survive the second pass + that exists to remove them. Only steps large enough to be real may be de-stepped. + """ + index = _index(days=700) + rng = np.random.default_rng(seed) + reference = np.cumsum(rng.normal(0.0, 2.0, size=len(index))) % 360.0 + scatter = np.random.default_rng(500 + seed).normal(0.0, 6.0, len(index)) + reported = (reference + veer_amplitude * np.cos(np.deg2rad(reference - 40.0)) + scatter) % 360.0 + + table = estimate_north_table(index, reported, reference_deg=reference, usable=_all_usable(index)) + + assert len(table) == 1, f"veer mistaken for {len(table) - 1} step(s): {table}" + class TestTransientPruning: """Site veer wanders away and back; a recalibration does not.""" From d96d6dce684adc07f86e57f9f8c89ceeaa88775c Mon Sep 17 00:00:00 2001 From: aclerc Date: Thu, 3 Sep 2026 12:10:02 +0100 Subject: [PATCH 08/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- .gitignore | 4 + .../2026-09-01-c1-campaign-runner-placebo.md | 1854 ----------------- .../2026-08-27-realistic-campaigns-design.md | 180 -- ...08-28-c1-campaign-runner-placebo-design.md | 210 -- ...6-08-28-robustness-failure-modes-design.md | 124 -- .../2026-08-28-w0-src-layout-rename-design.md | 154 -- ...6-09-01-c2-campaign-context-seam-design.md | 333 --- .../specs/2026-09-02-r1-northing-design.md | 851 -------- docs/v1/issues_campaigns.md | 6 + 9 files changed, 10 insertions(+), 3706 deletions(-) delete mode 100644 docs/superpowers/plans/2026-09-01-c1-campaign-runner-placebo.md delete mode 100644 docs/superpowers/specs/2026-08-27-realistic-campaigns-design.md delete mode 100644 docs/superpowers/specs/2026-08-28-c1-campaign-runner-placebo-design.md delete mode 100644 docs/superpowers/specs/2026-08-28-robustness-failure-modes-design.md delete mode 100644 docs/superpowers/specs/2026-08-28-w0-src-layout-rename-design.md delete mode 100644 docs/superpowers/specs/2026-09-01-c2-campaign-context-seam-design.md delete mode 100644 docs/superpowers/specs/2026-09-02-r1-northing-design.md diff --git a/.gitignore b/.gitignore index 21f3b597..5a7e8af8 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,7 @@ output/ # matplotlib testing **/result_images/ /docs/v1/extra_docs/ + +# local working documents, not part of the published project +/CLAUDE.md +/docs/superpowers/ diff --git a/docs/superpowers/plans/2026-09-01-c1-campaign-runner-placebo.md b/docs/superpowers/plans/2026-09-01-c1-campaign-runner-placebo.md deleted file mode 100644 index 9b6fb7c4..00000000 --- a/docs/superpowers/plans/2026-09-01-c1-campaign-runner-placebo.md +++ /dev/null @@ -1,1854 +0,0 @@ -# C1 — Campaign declaration + runner + farm uplift + placebo campaign — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Stand 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 and every method reports ~0. - -**Architecture:** A private `SyntheticCampaign` (holds the injected upgrades = ground truth) derives a public `CampaignSpec` (facts a method may see). A `CampaignRunner` loops the upgraded turbines, runs each applicable method once through the harness's `score_one` (capturing the `MethodOutput` on the way past, so one estimate call serves both output shapes), aggregates per-turbine estimates into one headline via the pure `wind_up.farm_uplift`, and compares against an exact pooled truth from `original_df`. A report module writes tables and plots. - -**Tech Stack:** Python 3.10+, pandas, numpy, matplotlib; `uv` + `poe`; pytest. No new dependencies. - -**Spec:** `docs/superpowers/specs/2026-08-28-c1-campaign-runner-placebo-design.md` - -## Global Constraints - -- **Never perform git write actions.** Do not commit, branch, push, tag, or stage. Each task ends with a **Checkpoint** step that reports to the user, who commits. This deliberately replaces the `git commit` step the writing-plans template uses. -- Style: `ruff` `line-length = 120`, `select = ["ALL"]`; `mypy` enforced. `poe lint` then `poe test-fast` before each checkpoint. Never run plain `poe test` (>10 min of `slow` tests). -- Tests treat warnings as errors (`filterwarnings = ["error", ...]`). -- **Docstrings state behaviour and usage, not justification.** No design rationale, no measured evidence, no finding numbers in source. Err far shorter than instinct. -- **Keyword arguments:** at most 1–2 positional args; put `*` after the obvious leading positional so the rest are keyword-only. -- `src/wind_up/` (v1 product) may not import from `benchmarking/` or `wind_up_v0/`. `benchmarking/` may import from `wind_up`. -- New v1 source tests live in `tests/wind_up/`; benchmarking tests mirror `benchmarking/` under `tests/benchmarking/`. - ---- - -### Task 1: `farm_uplift` — the pure headline function - -**Files:** -- Create: `src/wind_up/farm.py` -- Modify: `src/wind_up/__init__.py` -- Create: `tests/wind_up/__init__.py` -- Test: `tests/wind_up/test_farm.py` - -**Interfaces:** -- Consumes: nothing (first task). -- Produces: `wind_up.farm.TurbineUplift(turbine: str, uplift: float, treated_energy: float, n_records: int, rated_power_kw: float)`; `wind_up.farm.FarmUplift(uplift: float, turbines: pd.DataFrame, uplift_spread: float)`; `wind_up.farm.farm_uplift(turbines: Sequence[TurbineUplift]) -> FarmUplift`. Re-exported as `wind_up.farm_uplift`, `wind_up.TurbineUplift`, `wind_up.FarmUplift`. - -- [ ] **Step 1: Write the failing tests** - -Create `tests/wind_up/__init__.py` (empty) and `tests/wind_up/test_farm.py`: - -```python -"""Tests for the farm-uplift headline and its guards.""" - -from __future__ import annotations - -import math - -import pytest - -from wind_up.farm import FarmUplift, TurbineUplift, farm_uplift - - -def _t( - name: str = "T1", - *, - uplift: float = 0.05, - treated_energy: float = 1000.0, - n_records: int = 100, - rated_power_kw: float = 2300.0, -) -> TurbineUplift: - return TurbineUplift( - turbine=name, - uplift=uplift, - treated_energy=treated_energy, - n_records=n_records, - rated_power_kw=rated_power_kw, - ) - - -def test_equal_uplifts_reproduce_the_pooled_ratio() -> None: - result = farm_uplift([_t("T1", treated_energy=1000.0), _t("T2", treated_energy=2000.0)]) - assert result.uplift == pytest.approx(0.05) - assert result.turbines["used"].all() - assert (result.turbines["guard"] == "").all() - - -def test_headline_weights_turbines_by_treated_energy() -> None: - # T2 carries 9x the energy, so the headline sits close to T2's 0.0. - result = farm_uplift( - [_t("T1", uplift=0.10, treated_energy=110.0), _t("T2", uplift=0.0, treated_energy=900.0)] - ) - counterfactual = 110.0 / 1.10 + 900.0 - assert result.uplift == pytest.approx((110.0 + 900.0) / counterfactual - 1.0) - assert result.uplift < 0.02 - - -def test_uplift_spread_reports_the_range_across_used_turbines() -> None: - result = farm_uplift([_t("T1", uplift=0.02), _t("T2", uplift=0.08), _t("T3", uplift=0.05)]) - assert result.uplift_spread == pytest.approx(0.06) - - -def test_uplift_spread_is_nan_for_a_single_turbine() -> None: - assert math.isnan(farm_uplift([_t("T1")]).uplift_spread) - - -def test_uplift_of_minus_one_is_dropped_not_divided_by_zero() -> None: - result = farm_uplift([_t("T1", uplift=-1.0), _t("T2", uplift=0.05, treated_energy=2000.0)]) - row = result.turbines.set_index("turbine").loc["T1"] - assert not row["used"] - assert row["guard"] == "negative_counterfactual" - assert result.uplift == pytest.approx(0.05) - - -def test_uplift_below_minus_one_is_dropped() -> None: - result = farm_uplift([_t("T1", uplift=-1.5), _t("T2", uplift=0.05, treated_energy=2000.0)]) - assert not result.turbines.set_index("turbine").loc["T1", "used"] - assert result.uplift == pytest.approx(0.05) - - -def test_implied_capacity_factor_above_rated_is_capped() -> None: - # u=-0.9 implies a counterfactual of 1000 kW-records over 10 records = 100 kW/record > 50 rated. - result = farm_uplift([_t("T1", uplift=-0.9, treated_energy=100.0, n_records=10, rated_power_kw=50.0)]) - row = result.turbines.set_index("turbine").loc["T1"] - assert row["used"] - assert row["guard"] == "capacity_cap" - assert row["counterfactual_energy"] == pytest.approx(500.0) - assert result.uplift == pytest.approx(100.0 / 500.0 - 1.0) - - -def test_negative_treated_energy_is_dropped() -> None: - result = farm_uplift([_t("T1", treated_energy=-5.0), _t("T2", treated_energy=2000.0)]) - row = result.turbines.set_index("turbine").loc["T1"] - assert not row["used"] - assert row["guard"] == "negative_energy" - - -def test_turbine_with_no_records_is_dropped() -> None: - result = farm_uplift([_t("T1", n_records=0, treated_energy=0.0), _t("T2")]) - assert result.turbines.set_index("turbine").loc["T1", "guard"] == "no_records" - - -def test_non_finite_uplift_is_dropped() -> None: - result = farm_uplift([_t("T1", uplift=float("nan")), _t("T2")]) - assert result.turbines.set_index("turbine").loc["T1", "guard"] == "non_finite_uplift" - - -def test_headline_is_nan_when_no_turbine_is_usable() -> None: - result = farm_uplift([_t("T1", uplift=float("nan"))]) - assert math.isnan(result.uplift) - - -def test_empty_input_raises() -> None: - with pytest.raises(ValueError, match="at least one turbine"): - farm_uplift([]) - - -def test_result_is_a_farm_uplift() -> None: - assert isinstance(farm_uplift([_t("T1")]), FarmUplift) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/wind_up/test_farm.py -q` -Expected: FAIL — `ModuleNotFoundError: No module named 'wind_up.farm'` - -- [ ] **Step 3: Write the implementation** - -Create `src/wind_up/farm.py`: - -```python -"""Combine per-turbine uplift estimates into one farm headline.""" - -from __future__ import annotations - -import math -from dataclasses import dataclass -from typing import TYPE_CHECKING - -import pandas as pd - -if TYPE_CHECKING: - from collections.abc import Sequence - - -@dataclass(frozen=True) -class TurbineUplift: - """One turbine's uplift estimate and the treated-period energy behind it. - - :param turbine: turbine name - :param uplift: the turbine's P50 uplift, as an energy-ratio fraction - :param treated_energy: observed treated-period energy — the sum of finite active power over - the treated records - :param n_records: how many records that sum covers - :param rated_power_kw: the rating the capacity-factor cap uses; where the rating changed over - the campaign, pass the higher of the pre- and post-change values - """ - - turbine: str - uplift: float - treated_energy: float - n_records: int - rated_power_kw: float - - -@dataclass(frozen=True) -class FarmUplift: - """The farm headline and the per-turbine detail behind it. - - :param uplift: the headline, ``(Σ treated energy) / (Σ counterfactual energy) − 1`` over the - used turbines; NaN when none are usable - :param turbines: one row per input turbine with ``turbine``, ``uplift``, ``treated_energy``, - ``n_records``, ``rated_power_kw``, ``counterfactual_energy``, ``used`` and ``guard`` - (``""`` when no guard fired) - :param uplift_spread: the max−min of the used turbines' uplifts; NaN below two used turbines - """ - - uplift: float - turbines: pd.DataFrame - uplift_spread: float - - -def farm_uplift(turbines: Sequence[TurbineUplift]) -> FarmUplift: - """Aggregate per-turbine uplifts into one energy-weighted farm headline. - - Each turbine's counterfactual energy is estimated as ``treated_energy / (1 + uplift)`` and - guarded: a turbine is dropped when its uplift is non-finite or ``<= -1``, when its treated - energy is negative, or when it has no records; a counterfactual implying a mean power above - ``rated_power_kw`` is clipped to that rating. - """ - if not turbines: - msg = "farm_uplift needs at least one turbine" - raise ValueError(msg) - - rows = [_evaluate(t) for t in turbines] - frame = pd.DataFrame(rows) - used = frame[frame["used"]] - - treated_total = float(used["treated_energy"].sum()) - counterfactual_total = float(used["counterfactual_energy"].sum()) - uplift = treated_total / counterfactual_total - 1.0 if counterfactual_total else float("nan") - - spreads = used["uplift"] - spread = float(spreads.max() - spreads.min()) if len(spreads) > 1 else float("nan") - return FarmUplift(uplift=uplift, turbines=frame, uplift_spread=spread) - - -def _evaluate(turbine: TurbineUplift) -> dict[str, object]: - """Return one turbine's row: its counterfactual energy, whether it is used, and any guard.""" - base: dict[str, object] = { - "turbine": turbine.turbine, - "uplift": turbine.uplift, - "treated_energy": turbine.treated_energy, - "n_records": turbine.n_records, - "rated_power_kw": turbine.rated_power_kw, - } - guard = _drop_reason(turbine) - if guard: - return {**base, "counterfactual_energy": float("nan"), "used": False, "guard": guard} - - counterfactual = max(turbine.treated_energy / (1.0 + turbine.uplift), 0.0) - cap = turbine.rated_power_kw * turbine.n_records - if counterfactual > cap: - return {**base, "counterfactual_energy": cap, "used": True, "guard": "capacity_cap"} - return {**base, "counterfactual_energy": counterfactual, "used": True, "guard": ""} - - -def _drop_reason(turbine: TurbineUplift) -> str: - """Name the guard that removes ``turbine`` from the weighting, or ``""`` to keep it.""" - if not math.isfinite(turbine.uplift): - return "non_finite_uplift" - if turbine.n_records <= 0: - return "no_records" - if turbine.treated_energy < 0: - return "negative_energy" - if 1.0 + turbine.uplift <= 0: - return "negative_counterfactual" - return "" -``` - -Modify `src/wind_up/__init__.py` — add below the existing `__version__` line: - -```python -from wind_up.farm import FarmUplift, TurbineUplift, farm_uplift - -__all__ = ["FarmUplift", "TurbineUplift", "__version__", "farm_uplift"] -``` - -(Keep the `from importlib.metadata import version` / `__version__` lines exactly as they are, and put the `farm` import after them.) - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `uv run pytest tests/wind_up/test_farm.py -q` -Expected: PASS (14 tests) - -- [ ] **Step 5: Lint** - -Run: `uv run poe lint` -Expected: ruff format/check clean, mypy clean. - -- [ ] **Step 6: Checkpoint — report to the user** - -Do **not** commit. Report: files added (`src/wind_up/farm.py`, `tests/wind_up/__init__.py`, `tests/wind_up/test_farm.py`), file modified (`src/wind_up/__init__.py`), test count, lint status. Flag the new untracked files so the user can `git add` them. - ---- - -### Task 2: `true_farm_uplift` — the exact pooled truth - -**Files:** -- Modify: `benchmarking/synthetic/ground_truth.py` -- Modify: `benchmarking/synthetic/generator.py` (add `SyntheticDataset.true_farm_uplift`) -- Modify: `benchmarking/synthetic/__init__.py` (export) -- Test: `tests/benchmarking/synthetic/test_ground_truth.py` (append; create if absent) - -**Interfaces:** -- Consumes: nothing from Task 1. -- Produces: `benchmarking.synthetic.true_farm_uplift(synthetic_df, original_df, *, test_wtgs: Sequence[str], masks: Mapping[str, npt.ArrayLike] | None = None, columns: ColumnSchema = HOT_COLUMNS) -> float`; `SyntheticDataset.true_farm_uplift(*, test_wtgs, masks=None) -> float`. - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/benchmarking/synthetic/test_ground_truth.py` (create the file with this content if it does not exist, adding the imports at the top): - -```python -"""Tests for the pooled farm-level ground truth.""" - -from __future__ import annotations - -import numpy as np -import pandas as pd -import pytest - -from benchmarking.synthetic import HOT_COLUMNS, true_farm_uplift - - -def _frames(powers: dict[str, list[float]], uplifts: dict[str, float]) -> tuple[pd.DataFrame, pd.DataFrame]: - """Build (synthetic, original) long frames from per-turbine original powers and a flat uplift.""" - index = pd.date_range("2020-01-01", periods=len(next(iter(powers.values()))), freq="10min", tz="UTC") - original = pd.concat( - [ - pd.DataFrame({HOT_COLUMNS.turbine: wtg, HOT_COLUMNS.active_power: values}, index=index) - for wtg, values in powers.items() - ] - ) - synthetic = original.copy() - for wtg, factor in uplifts.items(): - rows = synthetic[HOT_COLUMNS.turbine] == wtg - synthetic.loc[rows, HOT_COLUMNS.active_power] = synthetic.loc[rows, HOT_COLUMNS.active_power] * (1 + factor) - return synthetic, original - - -def test_farm_truth_pools_energy_across_turbines() -> None: - synthetic, original = _frames({"T1": [100.0, 100.0], "T2": [300.0, 300.0]}, {"T1": 0.10, "T2": 0.0}) - # (220 + 600) / (200 + 600) - 1 = 0.025 - assert true_farm_uplift(synthetic, original, test_wtgs=["T1", "T2"]) == pytest.approx(0.025) - - -def test_farm_truth_honours_per_turbine_masks() -> None: - synthetic, original = _frames({"T1": [100.0, 100.0], "T2": [300.0, 300.0]}, {"T1": 0.10, "T2": 0.0}) - masks = {"T1": np.array([True, False]), "T2": np.array([False, True])} - # (110 + 300) / (100 + 300) - 1 = 0.025 - assert true_farm_uplift(synthetic, original, test_wtgs=["T1", "T2"], masks=masks) == pytest.approx(0.025) - - -def test_farm_truth_ignores_records_with_non_finite_power() -> None: - synthetic, original = _frames({"T1": [100.0, np.nan], "T2": [300.0, 300.0]}, {"T1": 0.10, "T2": 0.0}) - assert true_farm_uplift(synthetic, original, test_wtgs=["T1", "T2"]) == pytest.approx( - (110.0 + 600.0) / (100.0 + 600.0) - 1.0 - ) - - -def test_farm_truth_is_zero_for_an_unchanged_farm() -> None: - synthetic, original = _frames({"T1": [100.0, 100.0], "T2": [300.0, 300.0]}, {}) - assert true_farm_uplift(synthetic, original, test_wtgs=["T1", "T2"]) == pytest.approx(0.0) - - -def test_farm_truth_is_nan_when_no_energy_survives() -> None: - synthetic, original = _frames({"T1": [0.0, 0.0]}, {}) - assert np.isnan(true_farm_uplift(synthetic, original, test_wtgs=["T1"])) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/benchmarking/synthetic/test_ground_truth.py -q` -Expected: FAIL — `ImportError: cannot import name 'true_farm_uplift'` - -- [ ] **Step 3: Write the implementation** - -In `benchmarking/synthetic/ground_truth.py`, add after `true_net_uplift` (and add `from collections.abc import Mapping, Sequence` to the `TYPE_CHECKING` block): - -```python -def true_farm_uplift( - synthetic_df: pd.DataFrame, - original_df: pd.DataFrame, - *, - test_wtgs: Sequence[str], - masks: Mapping[str, npt.ArrayLike] | None = None, - columns: ColumnSchema = HOT_COLUMNS, -) -> float: - """Pooled energy-ratio uplift across several upgraded turbines. - - ``(Σᵢ synthetic energy) / (Σᵢ original energy) − 1`` over each turbine's own selected finite - records — the N-turbine form of :func:`true_net_uplift`. - - :param synthetic_df: method-facing synthetic SCADA - :param original_df: untouched original SCADA (ground-truth reference) - :param test_wtgs: the upgraded turbines to pool - :param masks: per-turbine boolean selections over that turbine's rows (time order); a turbine - absent from the mapping, or ``masks=None``, uses the records the upgrade actually changed - :param columns: the source-native column schema the frames are keyed by - """ - synthetic_total = 0.0 - original_total = 0.0 - for wtg in test_wtgs: - synthetic_power = synthetic_df.loc[synthetic_df[columns.turbine] == wtg, columns.active_power].to_numpy( - dtype=float - ) - original_power = original_df.loc[original_df[columns.turbine] == wtg, columns.active_power].to_numpy( - dtype=float - ) - selection = None if masks is None else masks.get(wtg) - row_mask = ( - changed_record_mask(synthetic_power, original_power) - if selection is None - else np.asarray(selection, dtype=bool) - ) - effective = row_mask & np.isfinite(synthetic_power) & np.isfinite(original_power) - synthetic_total += synthetic_power[effective].sum() - original_total += original_power[effective].sum() - return float(synthetic_total / original_total - 1.0) if original_total else float("nan") -``` - -In `benchmarking/synthetic/generator.py`, import `true_farm_uplift` alongside the existing ground-truth imports and add this method to `SyntheticDataset`, after `true_net_uplift`: - -```python - def true_farm_uplift(self, *, test_wtgs: list[str], masks: dict[str, np.ndarray] | None = None) -> float: - """Derive the pooled farm uplift across ``test_wtgs`` (synthetic vs original).""" - return true_farm_uplift( - self.synthetic_df, self.original_df, test_wtgs=test_wtgs, masks=masks, columns=self.columns - ) -``` - -In `benchmarking/synthetic/__init__.py`, add `true_farm_uplift` to the `ground_truth` import line and to `__all__` (keep `__all__` alphabetically sorted — it goes immediately before `"true_net_uplift"`). - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `uv run pytest tests/benchmarking/synthetic/ -q` -Expected: PASS, existing synthetic tests still green. - -- [ ] **Step 5: Lint** - -Run: `uv run poe lint` - -- [ ] **Step 6: Checkpoint — report to the user** - -Do not commit. Report the new function, the `SyntheticDataset` method, the export, and test results. - ---- - -### Task 3: `CampaignSpec` and `SyntheticCampaign` - -**Files:** -- Create: `benchmarking/campaigns/__init__.py` -- Create: `benchmarking/campaigns/declaration.py` -- Create: `tests/benchmarking/campaigns/__init__.py` -- Test: `tests/benchmarking/campaigns/test_declaration.py` - -**Interfaces:** -- Consumes: `benchmarking.synthetic.{ColumnSchema, HOT_COLUMNS, SyntheticDataset, ToggleSchedule, generate_dataset, treated_mask}`. -- Produces: - - `CampaignSpec(upgraded_turbines: list[str], upgrade_timing: pd.Timestamp | ToggleSchedule, candidate_references: list[str], excluded_turbines: list[str], coords: dict[str, tuple[float, float]], north_offsets: list[tuple[str, pd.Timestamp, float]], rated_power_kw: float, analysis_period: tuple[pd.Timestamp, pd.Timestamp], turbine_col: str)` with `.mode -> Literal["prepost", "toggle"]`, `.timing_for(wtg) -> pd.Timestamp | ToggleSchedule`, `.usable_mask(wtg, index) -> npt.NDArray[np.bool_]`, `.change_label() -> str`, `.treatment_start -> pd.Timestamp`. - - `SyntheticCampaign(upgraded_turbines, upgrade_timing, candidate_references, upgrades, coords, north_offsets, rated_power_kw, analysis_period, excluded_turbines=[], columns=HOT_COLUMNS, seed=0)` with `.spec() -> CampaignSpec`, `.generate(scada_df) -> SyntheticDataset`, `.turbines -> list[str]`. - -- [ ] **Step 1: Write the failing tests** - -Create `tests/benchmarking/campaigns/__init__.py` (empty) and `tests/benchmarking/campaigns/test_declaration.py`: - -```python -"""Tests for the campaign declaration and the public spec derived from it.""" - -from __future__ import annotations - -import dataclasses - -import numpy as np -import pandas as pd -import pytest - -from benchmarking.campaigns import CampaignSpec, SyntheticCampaign -from benchmarking.synthetic import HOT_COLUMNS, ConstantCpChange, ToggleSchedule - -PERIOD = (pd.Timestamp("2020-01-01", tz="UTC"), pd.Timestamp("2020-07-01", tz="UTC")) -CHANGEOVER = pd.Timestamp("2020-04-01", tz="UTC") - - -def _campaign(*, upgrades: list | None = None, upgrade_timing: object = CHANGEOVER) -> SyntheticCampaign: - return SyntheticCampaign( - upgraded_turbines=["T1", "T2"], - upgrade_timing=upgrade_timing, - candidate_references=["T3", "T4", "T5"], - excluded_turbines=["T5"], - upgrades=[] if upgrades is None else upgrades, - coords={f"T{i}": (57.5 + i * 0.01, -3.25) for i in range(1, 6)}, - north_offsets=[("T1", pd.Timestamp("2020-01-01", tz="UTC"), 1.5)], - rated_power_kw=2300.0, - analysis_period=PERIOD, - ) - - -def _scada(turbines: tuple[str, ...] = ("T1", "T2", "T3", "T4", "T5")) -> pd.DataFrame: - index = pd.date_range(PERIOD[0], PERIOD[1], freq="1h", tz="UTC", inclusive="left") - frames = [ - pd.DataFrame( - { - HOT_COLUMNS.turbine: wtg, - HOT_COLUMNS.active_power: 900.0, - HOT_COLUMNS.wind_speed: 8.0, - HOT_COLUMNS.wind_speed_sd: 0.8, - HOT_COLUMNS.gen_rpm: 1400.0, - HOT_COLUMNS.availability: 3600.0, - }, - index=index, - ) - for wtg in turbines - ] - return pd.concat(frames) - - -def test_spec_exposes_no_upgrade_physics() -> None: - campaign = _campaign(upgrades=[ConstantCpChange(delta=0.05)]) - spec = campaign.spec() - fields = {f.name for f in dataclasses.fields(spec)} - assert "upgrades" not in fields - assert not any("upgrade" in f and "timing" not in f and "turbines" not in f for f in fields - {"upgraded_turbines"}) - # nothing on the spec, at any depth of its repr, leaks the injected magnitude - assert "0.05" not in repr(spec) - - -def test_spec_carries_the_public_facts() -> None: - spec = _campaign().spec() - assert spec.upgraded_turbines == ["T1", "T2"] - assert spec.candidate_references == ["T3", "T4", "T5"] - assert spec.excluded_turbines == ["T5"] - assert spec.rated_power_kw == 2300.0 - assert spec.analysis_period == PERIOD - assert spec.turbine_col == HOT_COLUMNS.turbine - - -def test_mode_is_prepost_for_a_changeover_timestamp() -> None: - assert _campaign().spec().mode == "prepost" - - -def test_mode_is_toggle_for_a_schedule() -> None: - schedule = ToggleSchedule(period=pd.Timedelta(hours=4), start=CHANGEOVER) - assert _campaign(upgrade_timing=schedule).spec().mode == "toggle" - - -def test_timing_for_returns_the_same_timing_for_every_upgraded_turbine() -> None: - spec = _campaign().spec() - assert spec.timing_for("T1") == CHANGEOVER - assert spec.timing_for("T2") == CHANGEOVER - - -def test_timing_for_rejects_a_turbine_that_is_not_upgraded() -> None: - with pytest.raises(KeyError, match="T3"): - _campaign().spec().timing_for("T3") - - -def test_usable_mask_keeps_every_record_of_a_participating_turbine() -> None: - spec = _campaign().spec() - index = pd.date_range(PERIOD[0], periods=5, freq="1h", tz="UTC") - assert spec.usable_mask("T3", index).all() - assert spec.usable_mask("T1", index).all() - - -def test_usable_mask_drops_every_record_of_an_excluded_turbine() -> None: - spec = _campaign().spec() - index = pd.date_range(PERIOD[0], periods=5, freq="1h", tz="UTC") - assert not spec.usable_mask("T5", index).any() - - -def test_change_label_is_neutral() -> None: - assert _campaign().spec().change_label() == "the change" - - -def test_treatment_start_is_the_changeover_for_prepost() -> None: - assert _campaign().spec().treatment_start == CHANGEOVER - - -def test_treatment_start_is_the_schedule_start_for_toggle() -> None: - schedule = ToggleSchedule(period=pd.Timedelta(hours=4), start=CHANGEOVER) - assert _campaign(upgrade_timing=schedule).spec().treatment_start == CHANGEOVER - - -def test_generate_returns_an_unchanged_dataset_when_there_are_no_upgrades() -> None: - dataset = _campaign().generate(_scada()) - pd.testing.assert_frame_equal(dataset.synthetic_df, dataset.original_df) - - -def test_generate_injects_the_declared_upgrade() -> None: - dataset = _campaign(upgrades=[ConstantCpChange(delta=0.05)]).generate(_scada()) - assert not dataset.synthetic_df[HOT_COLUMNS.active_power].equals( - dataset.original_df[HOT_COLUMNS.active_power] - ) - - -def test_generate_restricts_the_data_to_the_analysis_period() -> None: - wide = _scada() - extra = wide.copy() - extra.index = extra.index - pd.Timedelta(days=90) - dataset = _campaign().generate(pd.concat([extra, wide])) - assert dataset.synthetic_df.index.min() >= PERIOD[0] - assert dataset.synthetic_df.index.max() < PERIOD[1] - - -def test_turbines_lists_every_declared_turbine() -> None: - assert _campaign().turbines == ["T1", "T2", "T3", "T4", "T5"] - - -def test_spec_is_a_campaign_spec() -> None: - assert isinstance(_campaign().spec(), CampaignSpec) - - -def test_usable_mask_length_matches_the_index( ) -> None: - spec = _campaign().spec() - index = pd.date_range(PERIOD[0], periods=7, freq="1h", tz="UTC") - assert spec.usable_mask("T3", index).shape == (7,) - assert spec.usable_mask("T3", index).dtype == np.bool_ -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/benchmarking/campaigns/test_declaration.py -q` -Expected: FAIL — `ModuleNotFoundError: No module named 'benchmarking.campaigns'` - -- [ ] **Step 3: Write the implementation** - -Create `benchmarking/campaigns/declaration.py`: - -```python -"""What a campaign is: the private declaration and the public spec derived from it. - -``SyntheticCampaign`` holds the injected upgrades and so is ground truth; ``CampaignSpec`` -carries only the facts an analyst would know and is what methods are given. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Literal - -import numpy as np -import pandas as pd - -from benchmarking.synthetic import HOT_COLUMNS, ToggleSchedule, generate_dataset - -if TYPE_CHECKING: - import numpy.typing as npt - - from benchmarking.synthetic import ColumnSchema, SyntheticDataset - - -@dataclass(frozen=True) -class CampaignSpec: - """The public facts of a campaign — everything a method may see, and nothing else. - - Read per-turbine facts through :meth:`timing_for` and :meth:`usable_mask` rather than the - flat fields, and the mode through :attr:`mode` rather than the type of ``upgrade_timing``. - - :param upgraded_turbines: the turbines whose uplift is being estimated - :param upgrade_timing: the changeover timestamp (prepost) or the ``ToggleSchedule`` (toggle) - :param candidate_references: turbines a method may use as references - :param excluded_turbines: turbines whose data must not be used at all - :param coords: turbine name → ``(latitude, longitude)`` in degrees - :param north_offsets: step-applied northing corrections, ``(turbine, from, offset_deg)`` - :param rated_power_kw: the turbines' rated power - :param analysis_period: ``(start, end)`` of the whole record, end exclusive - :param turbine_col: the turbine-identifier column of the SCADA frame - """ - - upgraded_turbines: list[str] - upgrade_timing: pd.Timestamp | ToggleSchedule - candidate_references: list[str] - excluded_turbines: list[str] - coords: dict[str, tuple[float, float]] - north_offsets: list[tuple[str, pd.Timestamp, float]] - rated_power_kw: float - analysis_period: tuple[pd.Timestamp, pd.Timestamp] - turbine_col: str = HOT_COLUMNS.turbine - - @property - def mode(self) -> Literal["prepost", "toggle"]: - """``"toggle"`` for a scheduled campaign, ``"prepost"`` for a single changeover.""" - return "toggle" if isinstance(self.upgrade_timing, ToggleSchedule) else "prepost" - - @property - def treatment_start(self) -> pd.Timestamp: - """When treatment begins: the changeover, or when toggling starts.""" - if isinstance(self.upgrade_timing, ToggleSchedule): - return self.upgrade_timing.start if self.upgrade_timing.start is not None else self.analysis_period[0] - return self.upgrade_timing - - def timing_for(self, turbine: str) -> pd.Timestamp | ToggleSchedule: - """The upgrade timing of one upgraded turbine.""" - if turbine not in self.upgraded_turbines: - msg = f"{turbine!r} is not an upgraded turbine of this campaign" - raise KeyError(msg) - return self.upgrade_timing - - def usable_mask(self, turbine: str, index: pd.DatetimeIndex) -> npt.NDArray[np.bool_]: - """Boolean mask over ``index`` of the records ``turbine``'s data may be used over.""" - usable = turbine not in self.excluded_turbines - return np.full(len(index), usable, dtype=bool) - - def change_label(self) -> str: - """How report and plot titles refer to what is being assessed.""" - return "the change" - - -@dataclass -class SyntheticCampaign: - """A declared campaign: its turbines and roles, its timing, and the upgrades to inject. - - Private to the benchmark — it holds the injected upgrades, which are the ground truth. - - :param upgraded_turbines: turbines to upgrade and estimate - :param upgrade_timing: changeover timestamp (prepost) or ``ToggleSchedule`` (toggle) - :param candidate_references: turbines offered to methods as references - :param upgrades: the upgrade callables to inject; empty for a placebo - :param coords: turbine name → ``(latitude, longitude)`` in degrees - :param north_offsets: step-applied northing corrections, ``(turbine, from, offset_deg)`` - :param rated_power_kw: the turbines' rated power - :param analysis_period: ``(start, end)`` of the whole record, end exclusive - :param excluded_turbines: turbines whose data must not be used - :param columns: the source-native column schema the SCADA is keyed by - :param seed: recorded in the generated dataset's run metadata - """ - - upgraded_turbines: list[str] - upgrade_timing: pd.Timestamp | ToggleSchedule - candidate_references: list[str] - upgrades: list - coords: dict[str, tuple[float, float]] - north_offsets: list[tuple[str, pd.Timestamp, float]] - rated_power_kw: float - analysis_period: tuple[pd.Timestamp, pd.Timestamp] - excluded_turbines: list[str] = field(default_factory=list) - columns: ColumnSchema = HOT_COLUMNS - seed: int = 0 - - @property - def turbines(self) -> list[str]: - """Every declared turbine, upgraded first, in declaration order and without duplicates.""" - seen: dict[str, None] = {} - for wtg in [*self.upgraded_turbines, *self.candidate_references]: - seen.setdefault(wtg, None) - return list(seen) - - def spec(self) -> CampaignSpec: - """Derive the public spec: the same campaign with the injected upgrades dropped.""" - return CampaignSpec( - upgraded_turbines=list(self.upgraded_turbines), - upgrade_timing=self.upgrade_timing, - candidate_references=list(self.candidate_references), - excluded_turbines=list(self.excluded_turbines), - coords=dict(self.coords), - north_offsets=list(self.north_offsets), - rated_power_kw=self.rated_power_kw, - analysis_period=self.analysis_period, - turbine_col=self.columns.turbine, - ) - - def generate(self, scada_df: pd.DataFrame) -> SyntheticDataset: - """Inject the declared upgrades into ``scada_df`` over the analysis period.""" - start, end = self.analysis_period - in_period = (scada_df.index >= start) & (scada_df.index < end) - declared = scada_df[self.columns.turbine].isin(self.turbines).to_numpy() - return generate_dataset( - scada_df=scada_df[in_period & declared], - test_wtgs=list(self.upgraded_turbines), - upgrades=list(self.upgrades), - mode="toggle" if isinstance(self.upgrade_timing, ToggleSchedule) else "prepost", - upgrade_timing=self.upgrade_timing, - rated_power_kw=self.rated_power_kw, - columns=self.columns, - seed=self.seed, - ) -``` - -Create `benchmarking/campaigns/__init__.py`: - -```python -"""Whole-farm campaigns: declare one, run it, and report against the known truth.""" - -from __future__ import annotations - -from benchmarking.campaigns.declaration import CampaignSpec, SyntheticCampaign - -__all__ = ["CampaignSpec", "SyntheticCampaign"] -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `uv run pytest tests/benchmarking/campaigns/test_declaration.py -q` -Expected: PASS (18 tests) - -- [ ] **Step 5: Lint** - -Run: `uv run poe lint` - -- [ ] **Step 6: Checkpoint — report to the user** - -Do not commit. Report the new `benchmarking/campaigns/` package and the new test package, and flag both as untracked. - ---- - -### Task 4: `carried_forward_methods` — the mode rule - -**Files:** -- Create: `benchmarking/campaigns/methods.py` -- Modify: `benchmarking/campaigns/__init__.py` -- Test: `tests/benchmarking/campaigns/test_methods.py` - -**Interfaces:** -- Consumes: `CampaignSpec` (Task 3). -- Produces: `carried_forward_methods(spec: CampaignSpec, *, out_dir: Path, era5_hourly_df: pd.DataFrame | None = None, include_power_model: bool = True) -> list[Method]`. - -- [ ] **Step 1: Write the failing test** - -Create `tests/benchmarking/campaigns/test_methods.py`: - -```python -"""Tests for the applicable-method rule.""" - -from __future__ import annotations - -import pandas as pd - -from benchmarking.campaigns import carried_forward_methods -from benchmarking.synthetic import ToggleSchedule - -from .test_declaration import CHANGEOVER, _campaign - - -def _names(spec, tmp_path) -> list[str]: - return [m.name for m in carried_forward_methods(spec, out_dir=tmp_path, include_power_model=False)] - - -def test_prepost_skips_the_toggle_specialist(tmp_path) -> None: - assert "toggle_specialist" not in _names(_campaign().spec(), tmp_path) - - -def test_toggle_includes_the_toggle_specialist(tmp_path) -> None: - schedule = ToggleSchedule(period=pd.Timedelta(hours=4), start=CHANGEOVER) - assert "toggle_specialist" in _names(_campaign(upgrade_timing=schedule).spec(), tmp_path) - - -def test_naive_ratio_runs_in_both_modes(tmp_path) -> None: - schedule = ToggleSchedule(period=pd.Timedelta(hours=4), start=CHANGEOVER) - assert "naive_ratio" in _names(_campaign().spec(), tmp_path) - assert "naive_ratio" in _names(_campaign(upgrade_timing=schedule).spec(), tmp_path) - - -def test_power_model_is_included_when_asked(tmp_path) -> None: - methods = carried_forward_methods(_campaign().spec(), out_dir=tmp_path, include_power_model=True) - assert "power_model" in [m.name for m in methods] - - -def test_each_method_writes_into_its_own_subfolder(tmp_path) -> None: - methods = carried_forward_methods(_campaign().spec(), out_dir=tmp_path, include_power_model=False) - out_dirs = {m.out_dir for m in methods} - assert len(out_dirs) == len(methods) - assert all(d.parent == tmp_path for d in out_dirs) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run pytest tests/benchmarking/campaigns/test_methods.py -q` -Expected: FAIL — `ImportError: cannot import name 'carried_forward_methods'` - -- [ ] **Step 3: Write the implementation** - -Create `benchmarking/campaigns/methods.py`: - -```python -"""Which methods a campaign runs, and how each is configured.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from benchmarking.baselines.naive_ratio import NaiveRatioMethod -from benchmarking.baselines.power_model import CURATED_ERA5_EXCLUDE, TUNED_MODEL_PARAMS, PowerModelMethod -from benchmarking.baselines.toggle_specialist import ToggleSpecialistMethod -from benchmarking.synthetic import HOT_COLUMNS - -if TYPE_CHECKING: - from pathlib import Path - - import pandas as pd - - from benchmarking.campaigns.declaration import CampaignSpec - from benchmarking.harness import Method - - -def carried_forward_methods( - spec: CampaignSpec, - *, - out_dir: Path, - era5_hourly_df: pd.DataFrame | None = None, - include_power_model: bool = True, -) -> list[Method]: - """Build the methods applicable to ``spec``, each writing into its own subfolder of ``out_dir``. - - ``toggle_specialist`` accepts only toggle campaigns and is left out of a prepost one. - - :param spec: the campaign being run - :param out_dir: the turbine's output folder; each method gets a subfolder named after it - :param era5_hourly_df: reanalysis for the power model; omit to run it without ERA5 features - :param include_power_model: build the power model (needs the ``ml`` dependency group) - """ - methods: list[Method] = [ - NaiveRatioMethod(columns=HOT_COLUMNS, out_dir=out_dir / "naive_ratio", save_plots=True) - ] - if spec.mode == "toggle": - methods.append( - ToggleSpecialistMethod( - columns=HOT_COLUMNS, - out_dir=out_dir / "toggle_specialist", - save_plots=True, - conditions=("power",), - rated_power_kw=spec.rated_power_kw, - ) - ) - if include_power_model: - methods.append( - PowerModelMethod( - columns=HOT_COLUMNS, - baseline_rated_power_kw=spec.rated_power_kw, - era5_hourly_df=era5_hourly_df, - availability_feature=False, - era5_exclude=CURATED_ERA5_EXCLUDE, - model_params=dict(TUNED_MODEL_PARAMS), - out_dir=out_dir / "power_model", - save_plots=True, - ) - ) - return methods -``` - -Add to `benchmarking/campaigns/__init__.py`: - -```python -from benchmarking.campaigns.methods import carried_forward_methods -``` - -and add `"carried_forward_methods"` to `__all__`. - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `uv run pytest tests/benchmarking/campaigns/test_methods.py -q` -Expected: PASS (5 tests). If `PowerModelMethod` rejects `era5_hourly_df=None` or requires `active_power_min`, adjust the constructor call to match its actual signature — read `benchmarking/baselines/power_model/method.py` and keep the test's `include_power_model=False` cases passing regardless. - -- [ ] **Step 5: Lint** - -Run: `uv run poe lint` - -- [ ] **Step 6: Checkpoint — report to the user** - -Do not commit. Report the mode rule and which methods each mode builds. - ---- - -### Task 5: `CampaignRunner` - -**Files:** -- Create: `benchmarking/campaigns/runner.py` -- Modify: `benchmarking/campaigns/__init__.py` -- Test: `tests/benchmarking/campaigns/test_runner.py` - -**Interfaces:** -- Consumes: `wind_up.farm_uplift`, `wind_up.TurbineUplift` (Task 1); `SyntheticDataset.true_farm_uplift` (Task 2); `CampaignSpec` (Task 3); harness `score_one`, `truth_mask`, `Replicate`, `CampaignWindow`. -- Produces: - - `CampaignResult(spec: CampaignSpec, scores: pd.DataFrame, farm: pd.DataFrame, farm_uplifts: dict[str, FarmUplift], truth_farm_uplift: float, outputs: dict[tuple[str, str], MethodOutput])` - - `CampaignRunner(spec: CampaignSpec, dataset: SyntheticDataset, *, build_methods: Callable[[str], list[Method]])` with `.run() -> CampaignResult` - - `per_turbine_table(result: CampaignResult) -> pd.DataFrame` (columns `method`, `test_wtg`, `estimate`, `truth`, `signed_error`) - -- [ ] **Step 1: Write the failing tests** - -Create `tests/benchmarking/campaigns/test_runner.py`: - -```python -"""Tests for the campaign runner: both output shapes, and a placebo reading ~0.""" - -from __future__ import annotations - -import numpy as np -import pandas as pd -import pytest - -from benchmarking.campaigns import CampaignRunner, per_turbine_table -from benchmarking.harness import MethodInput, MethodOutput -from benchmarking.synthetic import HOT_COLUMNS, ToggleSchedule - -from .test_declaration import CHANGEOVER, PERIOD, _campaign, _scada - -TOLERANCE = 1e-9 - - -class _ZeroMethod: - """Reports exactly zero uplift, whatever it is given.""" - - name = "zero" - - def estimate(self, mi: MethodInput) -> MethodOutput: # noqa: ARG002 - return MethodOutput(p50_overall=0.0) - - -class _OffsetMethod: - """Reports a fixed non-zero uplift, for checking the farm headline is not hard-wired to 0.""" - - name = "offset" - - def __init__(self, offset: float) -> None: - self._offset = offset - - def estimate(self, mi: MethodInput) -> MethodOutput: # noqa: ARG002 - return MethodOutput(p50_overall=self._offset) - - -class _RecordingMethod: - """Captures every MethodInput it sees.""" - - name = "recording" - - def __init__(self) -> None: - self.seen: list[MethodInput] = [] - - def estimate(self, mi: MethodInput) -> MethodOutput: - self.seen.append(mi) - return MethodOutput(p50_overall=0.0) - - -def _run(methods, *, upgrade_timing=CHANGEOVER): - campaign = _campaign(upgrade_timing=upgrade_timing) - dataset = campaign.generate(_scada()) - runner = CampaignRunner(campaign.spec(), dataset, build_methods=lambda _wtg: list(methods)) - return runner.run() - - -def test_placebo_per_turbine_estimates_are_zero_prepost() -> None: - result = _run([_ZeroMethod()]) - table = per_turbine_table(result) - assert set(table["test_wtg"]) == {"T1", "T2"} - assert table["truth"].abs().max() < TOLERANCE - assert table["signed_error"].abs().max() < TOLERANCE - - -def test_placebo_per_turbine_estimates_are_zero_toggle() -> None: - schedule = ToggleSchedule(period=pd.Timedelta(hours=8), start=CHANGEOVER) - table = per_turbine_table(_run([_ZeroMethod()], upgrade_timing=schedule)) - assert table["truth"].abs().max() < TOLERANCE - assert table["signed_error"].abs().max() < TOLERANCE - - -def test_placebo_farm_headline_is_zero_in_both_modes() -> None: - schedule = ToggleSchedule(period=pd.Timedelta(hours=8), start=CHANGEOVER) - for timing in (CHANGEOVER, schedule): - result = _run([_ZeroMethod()], upgrade_timing=timing) - assert abs(result.truth_farm_uplift) < TOLERANCE - assert abs(result.farm_uplifts["zero"].uplift) < TOLERANCE - assert result.farm["signed_error"].abs().max() < TOLERANCE - - -def test_farm_headline_follows_the_method_not_the_truth() -> None: - result = _run([_OffsetMethod(0.04)]) - assert result.farm_uplifts["offset"].uplift == pytest.approx(0.04) - row = result.farm.set_index("method").loc["offset"] - assert row["truth"] == pytest.approx(0.0, abs=TOLERANCE) - assert row["signed_error"] == pytest.approx(0.04) - - -def test_scores_are_the_tidy_harness_rows() -> None: - result = _run([_ZeroMethod()]) - expected = {"method", "test_wtg", "estimate", "truth", "signed_error", "treatment_start", "activity_end"} - assert expected <= set(result.scores.columns) - overall = result.scores[result.scores["condition"] == "overall"] - assert len(overall) == 2 # one per upgraded turbine, n=1 campaign - - -def test_each_method_is_estimated_once_per_upgraded_turbine() -> None: - recording = _RecordingMethod() - _run([recording]) - assert len(recording.seen) == 2 - assert {mi.test_wtg for mi in recording.seen} == {"T1", "T2"} - - -def test_methods_never_see_an_excluded_turbine() -> None: - recording = _RecordingMethod() - _run([recording]) - for mi in recording.seen: - assert "T5" not in set(mi.scada_df[HOT_COLUMNS.turbine]) - - -def test_methods_see_only_the_analysis_period() -> None: - recording = _RecordingMethod() - _run([recording]) - for mi in recording.seen: - assert mi.scada_df.index.min() >= PERIOD[0] - assert mi.scada_df.index.max() < PERIOD[1] - - -def test_outputs_are_kept_for_every_method_and_turbine() -> None: - result = _run([_ZeroMethod()]) - assert set(result.outputs) == {("zero", "T1"), ("zero", "T2")} - assert all(isinstance(o, MethodOutput) for o in result.outputs.values()) - - -def test_farm_table_reports_the_spread_and_guard_count() -> None: - result = _run([_ZeroMethod()]) - row = result.farm.set_index("method").loc["zero"] - assert "uplift_spread" in result.farm.columns - assert row["n_guarded"] == 0 - - -def test_treated_energy_matches_the_records_the_truth_uses() -> None: - # the estimate side sums finite synthetic power; the truth sums the same records - result = _run([_ZeroMethod()]) - detail = result.farm_uplifts["zero"].turbines - assert (detail["n_records"] > 0).all() - assert np.isfinite(detail["treated_energy"]).all() -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/benchmarking/campaigns/test_runner.py -q` -Expected: FAIL — `ImportError: cannot import name 'CampaignRunner'` - -- [ ] **Step 3: Write the implementation** - -Create `benchmarking/campaigns/runner.py`: - -```python -"""Run a declared campaign: per-turbine estimates, one farm headline, both output shapes.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING - -import numpy as np -import pandas as pd - -from benchmarking.harness import CampaignWindow, MethodOutput, Replicate, score_one, truth_mask -from benchmarking.synthetic import treated_mask -from wind_up import TurbineUplift, farm_uplift - -if TYPE_CHECKING: - from collections.abc import Callable - - from benchmarking.campaigns.declaration import CampaignSpec - from benchmarking.harness import Method, MethodInput - from benchmarking.synthetic import SyntheticDataset - from wind_up import FarmUplift - - -@dataclass -class CampaignResult: - """Everything one campaign run produced. - - :param spec: the campaign that was run - :param scores: the tidy harness rows, one set per upgraded turbine at n=1 - :param farm: one row per method — ``estimate``, ``truth``, ``signed_error``, - ``uplift_spread`` and ``n_guarded`` - :param farm_uplifts: each method's full :class:`~wind_up.FarmUplift`, including per-turbine detail - :param truth_farm_uplift: the exact pooled farm truth - :param outputs: each ``(method, turbine)``'s raw :class:`~benchmarking.harness.MethodOutput` - """ - - spec: CampaignSpec - scores: pd.DataFrame - farm: pd.DataFrame - farm_uplifts: dict[str, FarmUplift] - truth_farm_uplift: float - outputs: dict[tuple[str, str], MethodOutput] - - -class _Capturing: - """Delegates to a method and keeps its output, so one estimate call serves both output shapes.""" - - def __init__(self, method: Method) -> None: - self._method = method - self.name = method.name - self.output: MethodOutput | None = None - - def estimate(self, mi: MethodInput) -> MethodOutput: - """Estimate via the wrapped method, retaining the output.""" - self.output = self._method.estimate(mi) - return self.output - - -class CampaignRunner: - """Turn a campaign spec plus its generated dataset into per-turbine and farm results. - - :param spec: the public campaign facts; methods see nothing else - :param dataset: the generated dataset, whose ``original_df`` supplies the truth - :param build_methods: given an upgraded turbine's name, the methods to run for it - """ - - def __init__( - self, - spec: CampaignSpec, - dataset: SyntheticDataset, - *, - build_methods: Callable[[str], list[Method]], - ) -> None: - self._spec = spec - self._dataset = dataset - self._build_methods = build_methods - - def run(self) -> CampaignResult: - """Run every applicable method on every upgraded turbine and aggregate to one headline.""" - spec = self._spec - method_frame = self._method_facing_frame() - window = self._window() - - score_rows: list[dict[str, object]] = [] - outputs: dict[tuple[str, str], MethodOutput] = {} - estimates: dict[str, list[TurbineUplift]] = {} - truth_masks: dict[str, np.ndarray] = {} - - for wtg in spec.upgraded_turbines: - replicate = Replicate( - dataset=self._subset_dataset(method_frame), - test_wtg=wtg, - treatment_start=spec.treatment_start, - upgrade_timing=spec.timing_for(wtg), - ) - mask = truth_mask(replicate, window) - truth_masks[wtg] = mask - truth = replicate.true_uplift(mask=mask).overall - energy, n_records = self._treated_energy(method_frame, turbine=wtg, mask=mask) - - for method in self._build_methods(wtg): - capturing = _Capturing(method) - score_rows.extend( - score_one( - capturing, - replicate=replicate, - window=window, - truth=truth, - mask=mask, - profile_name=spec.change_label(), - ) - ) - assert capturing.output is not None # noqa: S101 - score_one always estimates - outputs[method.name, wtg] = capturing.output - estimates.setdefault(method.name, []).append( - TurbineUplift( - turbine=wtg, - uplift=capturing.output.p50_overall, - treated_energy=energy, - n_records=n_records, - rated_power_kw=spec.rated_power_kw, - ) - ) - - truth_farm = self._dataset.true_farm_uplift(test_wtgs=list(spec.upgraded_turbines), masks=truth_masks) - farm_uplifts = {name: farm_uplift(rows) for name, rows in estimates.items()} - farm = pd.DataFrame( - [ - { - "method": name, - "estimate": result.uplift, - "truth": truth_farm, - "signed_error": result.uplift - truth_farm, - "uplift_spread": result.uplift_spread, - "n_guarded": int((result.turbines["guard"] != "").sum()), - } - for name, result in farm_uplifts.items() - ] - ) - return CampaignResult( - spec=spec, - scores=pd.DataFrame(score_rows), - farm=farm, - farm_uplifts=farm_uplifts, - truth_farm_uplift=truth_farm, - outputs=outputs, - ) - - def _method_facing_frame(self) -> pd.DataFrame: - """The synthetic rows a method may see: within the analysis period, usable turbines only.""" - spec = self._spec - frame = self._dataset.synthetic_df - start, end = spec.analysis_period - keep = np.asarray((frame.index >= start) & (frame.index < end)) - for turbine, rows in frame.groupby(frame[spec.turbine_col], sort=False): - usable = spec.usable_mask(str(turbine), pd.DatetimeIndex(rows.index)) - keep[frame[spec.turbine_col].to_numpy() == turbine] &= usable - return frame[keep] - - def _subset_dataset(self, method_frame: pd.DataFrame) -> SyntheticDataset: - """The dataset restricted to the method-facing rows, truth frame kept aligned.""" - from dataclasses import replace # noqa: PLC0415 - local to keep the module import list flat - - original = self._dataset.original_df - aligned = original.loc[original.index.isin(method_frame.index)] - aligned = aligned[aligned[self._spec.turbine_col].isin(method_frame[self._spec.turbine_col].unique())] - return replace(self._dataset, synthetic_df=method_frame, original_df=aligned) - - def _window(self) -> CampaignWindow: - """One window spanning the whole campaign, so the harness scores it at n=1.""" - start, end = self._spec.analysis_period - treatment_start = self._spec.treatment_start - months = (end.year - treatment_start.year) * 12 + (end.month - treatment_start.month) - return CampaignWindow( - length=months, - unit="months", - baseline_start=start, - treatment_start=treatment_start, - activity_end=end, - ) - - def _treated_energy(self, frame: pd.DataFrame, *, turbine: str, mask: np.ndarray) -> tuple[float, int]: - """Observed treated-period energy and record count for one turbine, finite records only.""" - columns = self._dataset.columns - power = frame.loc[frame[columns.turbine] == turbine, columns.active_power].to_numpy(dtype=float) - selected = mask & np.isfinite(power) - return float(power[selected].sum()), int(selected.sum()) - - -def per_turbine_table(result: CampaignResult) -> pd.DataFrame: - """The per-turbine headline rows: one per method and upgraded turbine.""" - overall = result.scores[result.scores["condition"] == "overall"] - return overall[["method", "test_wtg", "estimate", "truth", "signed_error"]].reset_index(drop=True) -``` - -Note on `treated_mask`: it is imported for the report's use in Task 6; if ruff flags it unused here, drop the import from this module. - -Add to `benchmarking/campaigns/__init__.py`: - -```python -from benchmarking.campaigns.runner import CampaignResult, CampaignRunner, per_turbine_table -``` - -and extend `__all__` with `"CampaignResult"`, `"CampaignRunner"`, `"per_turbine_table"` (keep it sorted). - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `uv run pytest tests/benchmarking/campaigns/ -q` -Expected: PASS. If `Replicate` construction or `score_one` complains about the dataset's `run_metadata` (`_conditional_rows` reads `run_metadata["rated_power_kw"]`), confirm `generate_dataset` recorded it — it does — and that `_subset_dataset` preserves `run_metadata`, which `dataclasses.replace` does. - -- [ ] **Step 5: Lint** - -Run: `uv run poe lint` - -- [ ] **Step 6: Checkpoint — report to the user** - -Do not commit. Report both output shapes working and the placebo reading ~0 in both modes. - ---- - -### Task 6: The inspection report - -**Files:** -- Create: `benchmarking/campaigns/report.py` -- Modify: `benchmarking/campaigns/__init__.py` -- Test: `tests/benchmarking/campaigns/test_report.py` - -**Interfaces:** -- Consumes: `CampaignResult`, `per_turbine_table` (Task 5); `conditional_truth_vs_estimate` from `benchmarking.baselines.inspect_prepost_hard_case`; `plot_conditional_uplift`, `condition_bins`, `CONDITIONS` from `benchmarking.harness`. -- Produces: `write_campaign_report(result: CampaignResult, dataset: SyntheticDataset, *, out_dir: Path) -> Path`. - -- [ ] **Step 1: Write the failing test** - -Create `tests/benchmarking/campaigns/test_report.py`: - -```python -"""Tests for the campaign inspection report.""" - -from __future__ import annotations - -import matplotlib as mpl - -mpl.use("Agg") - -import pandas as pd - -from benchmarking.campaigns import CampaignRunner, write_campaign_report -from benchmarking.harness import MethodInput, MethodOutput - -from .test_declaration import CHANGEOVER, _campaign, _scada - - -class _ZeroMethod: - name = "zero" - - def estimate(self, mi: MethodInput) -> MethodOutput: # noqa: ARG002 - return MethodOutput(p50_overall=0.0) - - -def _result(): - campaign = _campaign(upgrade_timing=CHANGEOVER) - dataset = campaign.generate(_scada()) - runner = CampaignRunner(campaign.spec(), dataset, build_methods=lambda _wtg: [_ZeroMethod()]) - return runner.run(), dataset - - -def test_report_writes_the_three_tables(tmp_path) -> None: - result, dataset = _result() - out = write_campaign_report(result, dataset, out_dir=tmp_path) - assert (out / "per_turbine.csv").exists() - assert (out / "farm_uplift.csv").exists() - assert (out / "scores.csv").exists() - - -def test_farm_table_records_the_spread_and_guards(tmp_path) -> None: - result, dataset = _result() - out = write_campaign_report(result, dataset, out_dir=tmp_path) - farm = pd.read_csv(out / "farm_uplift.csv") - assert {"method", "estimate", "truth", "signed_error", "uplift_spread", "n_guarded"} <= set(farm.columns) - - -def test_per_turbine_detail_is_written_for_each_method(tmp_path) -> None: - result, dataset = _result() - out = write_campaign_report(result, dataset, out_dir=tmp_path) - detail = pd.read_csv(out / "farm_uplift_detail.csv") - assert set(detail["turbine"]) == {"T1", "T2"} - assert "guard" in detail.columns - - -def test_report_returns_the_directory_it_wrote_to(tmp_path) -> None: - result, dataset = _result() - assert write_campaign_report(result, dataset, out_dir=tmp_path) == tmp_path - - -def test_report_skips_conditional_plots_when_no_method_reports_conditions(tmp_path) -> None: - result, dataset = _result() - out = write_campaign_report(result, dataset, out_dir=tmp_path) - assert not list((out / "conditional").glob("*.png")) if (out / "conditional").exists() else True -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run pytest tests/benchmarking/campaigns/test_report.py -q` -Expected: FAIL — `ImportError: cannot import name 'write_campaign_report'` - -- [ ] **Step 3: Write the implementation** - -Create `benchmarking/campaigns/report.py`: - -```python -"""The whole-farm inspection report: per-turbine and farm tables, plus diagnostic plots.""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING - -import matplotlib.pyplot as plt -import pandas as pd - -from benchmarking.baselines.inspect_prepost_hard_case import conditional_truth_vs_estimate -from benchmarking.campaigns.runner import per_turbine_table -from benchmarking.harness import CONDITIONS, condition_bins, plot_conditional_uplift -from benchmarking.synthetic import treated_mask - -if TYPE_CHECKING: - from pathlib import Path - - from benchmarking.campaigns.runner import CampaignResult - from benchmarking.synthetic import SyntheticDataset - -logger = logging.getLogger(__name__) - - -def write_campaign_report(result: CampaignResult, dataset: SyntheticDataset, *, out_dir: Path) -> Path: - """Write the campaign's tables and plots under ``out_dir`` and return it. - - Writes ``per_turbine.csv``, ``farm_uplift.csv``, ``farm_uplift_detail.csv`` and ``scores.csv``, - plus one conditional uplift plot per condition for each method that reports per-condition - estimates. - """ - out_dir.mkdir(parents=True, exist_ok=True) - per_turbine = per_turbine_table(result) - per_turbine.to_csv(out_dir / "per_turbine.csv", index=False) - result.farm.to_csv(out_dir / "farm_uplift.csv", index=False) - result.scores.to_csv(out_dir / "scores.csv", index=False) - - detail = pd.concat( - [frame.turbines.assign(method=name) for name, frame in result.farm_uplifts.items()], ignore_index=True - ) - detail.to_csv(out_dir / "farm_uplift_detail.csv", index=False) - - logger.info("Per-turbine results for %s:\n%s", result.spec.change_label(), per_turbine.to_string(index=False)) - logger.info("Farm uplift:\n%s", result.farm.to_string(index=False)) - - _write_conditional_plots(result, dataset, out_dir=out_dir / "conditional") - return out_dir - - -def _write_conditional_plots(result: CampaignResult, dataset: SyntheticDataset, *, out_dir: Path) -> None: - """One conditional-uplift plot per condition, for every method that reports per-condition rows.""" - spec = result.spec - for (method_name, wtg), output in result.outputs.items(): - if output.p50_by_condition is None: - continue - rows = dataset.synthetic_df[dataset.synthetic_df[spec.turbine_col] == wtg] - mask = treated_mask(pd.DatetimeIndex(rows.index), spec.timing_for(wtg)) - truth_by_condition = { - condition: dataset.true_uplift( - test_wtg=wtg, - mask=mask, - by=condition, - bins=condition_bins(condition, rated_power_kw=spec.rated_power_kw), - ).by_condition - for condition in CONDITIONS - } - clean = {c: frame for c, frame in truth_by_condition.items() if frame is not None} - if not clean: - continue - out_dir.mkdir(parents=True, exist_ok=True) - frame = conditional_truth_vs_estimate(output, clean, method_name=method_name) - for condition in clean: - fig = plot_conditional_uplift( - frame, - condition=condition, - save_path=out_dir / f"conditional_uplift_{condition}_{wtg}_{method_name}.png", - title=f"Conditional uplift ({condition}) — {wtg}, {method_name} vs truth", - ) - plt.close(fig) -``` - -Add to `benchmarking/campaigns/__init__.py`: - -```python -from benchmarking.campaigns.report import write_campaign_report -``` - -and add `"write_campaign_report"` to `__all__`. - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `uv run pytest tests/benchmarking/campaigns/ -q` -Expected: PASS. - -- [ ] **Step 5: Lint** - -Run: `uv run poe lint` - -- [ ] **Step 6: Checkpoint — report to the user** - -Do not commit. Report the report contents and any plots produced. - ---- - -### Task 7: The two placebo campaigns and their driver - -**Files:** -- Create: `benchmarking/campaigns/placebo.py` -- Test: `tests/benchmarking/campaigns/test_placebo.py` - -**Interfaces:** -- Consumes: everything above; `benchmarking.baselines.hot_context.{NORTHING_YAML, build_hot_v0_context}`; `benchmarking.synthetic.sources.hill_of_towie.{load_hot_scada, load_hot_metadata}`. -- Produces: `placebo_campaign(mode: Literal["prepost", "toggle"]) -> SyntheticCampaign`; `run_placebo(*, mode, include_power_model=True, include_v0=False, out_root=None) -> CampaignResult`; `PLACEBO_TURBINES`, `PLACEBO_PERIOD`, `PLACEBO_CHANGEOVER`. - -- [ ] **Step 1: Write the failing test** - -Create `tests/benchmarking/campaigns/test_placebo.py`: - -```python -"""Tests for the two declared placebo campaigns.""" - -from __future__ import annotations - -import pandas as pd -import pytest - -from benchmarking.campaigns import CampaignRunner, per_turbine_table -from benchmarking.campaigns.placebo import PLACEBO_CHANGEOVER, PLACEBO_PERIOD, PLACEBO_TURBINES, placebo_campaign -from benchmarking.harness import MethodInput, MethodOutput -from benchmarking.synthetic import HOT_COLUMNS, ToggleSchedule - -TOLERANCE = 1e-9 - - -class _ZeroMethod: - name = "zero" - - def estimate(self, mi: MethodInput) -> MethodOutput: # noqa: ARG002 - return MethodOutput(p50_overall=0.0) - - -def _fixture_scada() -> pd.DataFrame: - """A tiny stand-in for the Hill of Towie download: flat power over the placebo period.""" - index = pd.date_range(PLACEBO_PERIOD[0], PLACEBO_PERIOD[1], freq="1h", tz="UTC", inclusive="left") - return pd.concat( - [ - pd.DataFrame( - { - HOT_COLUMNS.turbine: wtg, - HOT_COLUMNS.active_power: 900.0, - HOT_COLUMNS.wind_speed: 8.0, - HOT_COLUMNS.wind_speed_sd: 0.8, - HOT_COLUMNS.gen_rpm: 1400.0, - HOT_COLUMNS.availability: 3600.0, - }, - index=index, - ) - for wtg in PLACEBO_TURBINES - ] - ) - - -@pytest.mark.parametrize("mode", ["prepost", "toggle"]) -def test_placebo_injects_nothing(mode: str) -> None: - campaign = placebo_campaign(mode) - assert campaign.upgrades == [] - dataset = campaign.generate(_fixture_scada()) - pd.testing.assert_frame_equal(dataset.synthetic_df, dataset.original_df) - - -@pytest.mark.parametrize("mode", ["prepost", "toggle"]) -def test_placebo_spec_mode_matches(mode: str) -> None: - assert placebo_campaign(mode).spec().mode == mode - - -def test_placebo_period_is_january_to_june_inclusive() -> None: - start, end = PLACEBO_PERIOD - assert (start.month, start.day) == (1, 1) - assert (end.month, end.day) == (7, 1) # end-exclusive, so June is included - assert PLACEBO_CHANGEOVER > start - assert PLACEBO_CHANGEOVER < end - - -def test_placebo_uses_about_six_turbines() -> None: - assert 5 <= len(PLACEBO_TURBINES) <= 7 - - -def test_toggle_placebo_declares_a_schedule() -> None: - assert isinstance(placebo_campaign("toggle").upgrade_timing, ToggleSchedule) - - -@pytest.mark.parametrize("mode", ["prepost", "toggle"]) -def test_placebo_runs_end_to_end_to_zero(mode: str) -> None: - campaign = placebo_campaign(mode) - dataset = campaign.generate(_fixture_scada()) - result = CampaignRunner(campaign.spec(), dataset, build_methods=lambda _wtg: [_ZeroMethod()]).run() - assert per_turbine_table(result)["signed_error"].abs().max() < TOLERANCE - assert abs(result.farm_uplifts["zero"].uplift) < TOLERANCE - assert abs(result.truth_farm_uplift) < TOLERANCE - - -def test_placebo_rejects_an_unknown_mode() -> None: - with pytest.raises(ValueError, match="mode"): - placebo_campaign("sideways") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `uv run pytest tests/benchmarking/campaigns/test_placebo.py -q` -Expected: FAIL — `ModuleNotFoundError: No module named 'benchmarking.campaigns.placebo'` - -- [ ] **Step 3: Write the implementation** - -Create `benchmarking/campaigns/placebo.py`: - -```python -"""The placebo campaigns: a whole farm with nothing injected, run end-to-end. - -Both modes are declared once here. With no upgrade the synthetic data equals the original, so -every method's per-turbine and farm estimate should read ~0. - -Run it:: - - uv run python -m benchmarking.campaigns.placebo - -Outputs land under ``WIND_UP_BENCHMARKING_OUTPUT_DIR``/``placebo``/``_/``. The -first run downloads and caches the Hill of Towie SCADA (Zenodo) and ERA5 (Open-Meteo). -""" - -from __future__ import annotations - -import logging -import os -from pathlib import Path -from typing import TYPE_CHECKING, Literal - -import matplotlib as mpl - -mpl.use("Agg") - -import pandas as pd -import yaml - -from benchmarking.baselines.hot_context import NORTHING_YAML, build_hot_v0_context -from benchmarking.campaigns.declaration import SyntheticCampaign -from benchmarking.campaigns.methods import carried_forward_methods -from benchmarking.campaigns.report import write_campaign_report -from benchmarking.campaigns.runner import CampaignRunner -from benchmarking.synthetic import HOT_RATED_POWER_KW, ToggleSchedule -from benchmarking.synthetic.sources.hill_of_towie import load_hot_metadata, load_hot_scada - -if TYPE_CHECKING: - from collections.abc import Sequence - - from benchmarking.campaigns.runner import CampaignResult - -logger = logging.getLogger(__name__) - -PLACEBO_TURBINES = ("T01", "T02", "T03", "T04", "T05", "T07") -PLACEBO_WTG_NUMBERS = [1, 2, 3, 4, 5, 7] -PLACEBO_UPGRADED = ("T01", "T04") -PLACEBO_EXCLUDED = ("T07",) -PLACEBO_PERIOD = (pd.Timestamp("2018-01-01", tz="UTC"), pd.Timestamp("2018-07-01", tz="UTC")) -PLACEBO_CHANGEOVER = pd.Timestamp("2018-04-01", tz="UTC") -PLACEBO_TOGGLE_PERIOD = pd.Timedelta(hours=12) - - -def default_output_root() -> Path: - """The directory this driver writes under; override with ``WIND_UP_BENCHMARKING_OUTPUT_DIR``.""" - root = Path(os.getenv("WIND_UP_BENCHMARKING_OUTPUT_DIR", Path.home() / "temp" / "wind-up-benchmarking")) - return root / "placebo" - - -def _north_offsets(turbines: Sequence[str]) -> list[tuple[str, pd.Timestamp, float]]: - """Step-applied north offsets for ``turbines`` from the vendored northing YAML (UTC).""" - data = yaml.safe_load(NORTHING_YAML.read_text()) - return [ - (str(name), pd.Timestamp(ts, tz="UTC"), float(offset)) - for (name, ts, offset) in data - if str(name) in set(turbines) - ] - - -def _coords() -> dict[str, tuple[float, float]]: - """Hill of Towie turbine coordinates for the placebo turbines.""" - metadata = load_hot_metadata() - return { - str(row.Name): (float(row.Latitude), float(row.Longitude)) - for row in metadata.itertuples() - if str(row.Name) in set(PLACEBO_TURBINES) - } - - -def placebo_campaign(mode: Literal["prepost", "toggle"], *, coords: dict | None = None) -> SyntheticCampaign: - """Declare the placebo campaign for ``mode``: a whole farm with no upgrade injected. - - :param mode: ``"prepost"`` or ``"toggle"`` - :param coords: turbine coordinates; loaded from the Hill of Towie metadata when omitted - """ - if mode == "prepost": - timing: pd.Timestamp | ToggleSchedule = PLACEBO_CHANGEOVER - elif mode == "toggle": - timing = ToggleSchedule(period=PLACEBO_TOGGLE_PERIOD, start=PLACEBO_CHANGEOVER) - else: - msg = f"unknown mode {mode!r}; expected 'prepost' or 'toggle'" - raise ValueError(msg) - return SyntheticCampaign( - upgraded_turbines=list(PLACEBO_UPGRADED), - upgrade_timing=timing, - candidate_references=[w for w in PLACEBO_TURBINES if w not in PLACEBO_UPGRADED], - excluded_turbines=list(PLACEBO_EXCLUDED), - upgrades=[], - coords=coords if coords is not None else {w: (0.0, 0.0) for w in PLACEBO_TURBINES}, - north_offsets=_north_offsets(PLACEBO_TURBINES), - rated_power_kw=HOT_RATED_POWER_KW, - analysis_period=PLACEBO_PERIOD, - ) - - -def run_placebo( - *, - mode: Literal["prepost", "toggle"], - include_power_model: bool = True, - out_root: str | Path | None = None, -) -> CampaignResult: - """Run one placebo campaign end-to-end and write its report. - - :param mode: ``"prepost"`` or ``"toggle"`` - :param include_power_model: run the power model as well as the fast methods - :param out_root: base output dir; defaults to :func:`default_output_root` - """ - root = Path(out_root) if out_root is not None else default_output_root() - run_dir = root / f"{mode}_{pd.Timestamp.now():%Y%m%d_%H%M%S}" - run_dir.mkdir(parents=True, exist_ok=True) - - scada_df, _ = load_hot_scada( - start_dt=PLACEBO_PERIOD[0], - end_dt_excl=PLACEBO_PERIOD[1], - wtg_numbers=PLACEBO_WTG_NUMBERS, - wtg_names=list(PLACEBO_TURBINES), - ) - campaign = placebo_campaign(mode, coords=_coords()) - dataset = campaign.generate(scada_df) - spec = campaign.spec() - era5 = build_hot_v0_context(wtg_names=list(PLACEBO_TURBINES)).reanalysis_datasets[0].data - - runner = CampaignRunner( - spec, - dataset, - build_methods=lambda wtg: carried_forward_methods( - spec, - out_dir=run_dir / wtg, - era5_hourly_df=era5, - include_power_model=include_power_model, - ), - ) - result = runner.run() - write_campaign_report(result, dataset, out_dir=run_dir) - return result - - -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") - for placebo_mode in ("prepost", "toggle"): - run_placebo(mode=placebo_mode) -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `uv run pytest tests/benchmarking/campaigns/ -q` -Expected: PASS. The tests never download Hill of Towie data — they call `placebo_campaign` (which defaults `coords`) and the tiny fixture. If `_north_offsets` reading the vendored YAML is slow or the file is missing in CI, make `placebo_campaign` accept `north_offsets=None` and skip the read; keep the driver passing the real ones. - -- [ ] **Step 5: Run the whole fast suite and lint** - -Run: `uv run poe lint` then `uv run poe test-fast` -Expected: all green, no regressions in existing tests. - -- [ ] **Step 6: Real-data smoke run (manual, optional but recommended)** - -Run: `uv run python -m benchmarking.campaigns.placebo` -Expected: both campaigns complete; `per_turbine.csv` and `farm_uplift.csv` show every method's estimate near 0 and `truth` exactly 0. Record the actual numbers for the user — a real method on real SCADA will not be exactly 0, and how close it lands is the interesting result of C1. - -- [ ] **Step 7: Checkpoint — report to the user** - -Do not commit. Report: full file list added, `poe all-fast` status, the placebo numbers from the smoke run, and every new untracked path so the user can `git add` them. - ---- - -## Self-Review - -**Spec coverage** - -| Spec section | Task | -|---|---| -| §1 module split — `src/wind_up/farm.py` | 1 | -| §1 module split — `benchmarking/campaigns/` (`declaration.py`, `runner.py`, `report.py`, placebo driver) | 3, 5, 6, 7 | -| §2 `CampaignSpec` public facts; test asserts no upgrade physics | 3 | -| §2 future-proofing — `spec.mode`, `timing_for`, `usable_mask`, `change_label` | 3 (defined), 5 + 6 (consumed) | -| §3 farm uplift, both guards, spread, guard flags | 1 | -| §3 truth headline from `original_df` | 2 | -| §4 runner: per-turbine methods, mode rule, truth, farm estimate, n=1 harness rows | 4, 5 | -| §5 output shape 1 — inspection report with tables, spread, guards, plots | 6 | -| §5 output shape 2 — tidy `score_one` frame | 5 (`CampaignResult.scores`) | -| §6 two placebo campaigns, ~6 turbines, Jan–June, `upgrades=[]`, one excluded | 7 | -| Testing — guard unit tests, brief-has-no-physics, fast tiny-fixture placebo both modes, both output shapes | 1, 3, 5, 7 | - -**Deliberately deferred, and why** -- **v0 is not wired in.** §4 says "v0 is included but optional (slow)". `V0BinnedMethod` needs a `HotV0Context` and a real HoT asset config, so it cannot run on the tiny fixture and would make the placebo driver's default path a multi-hour run. The seam accepts it unchanged — `carried_forward_methods` gains an `include_v0` branch in one place. **Raise this with the user at the Task 4 checkpoint**: if they want it in C1, it is one constructor call plus a driver flag. -- **No reference selection in the runner.** The seam bakes reference selection into each `Method`; automatic selection from the spec is explicitly C3. - -**Placeholder scan:** no TBDs; every step carries runnable code or an exact command. Two steps name a fallback if a real signature differs (Task 4 Step 4, Task 7 Step 4) — those are contingencies with a stated action, not placeholders. - -**Type consistency:** `TurbineUplift`/`FarmUplift`/`farm_uplift` (Task 1) are used with those exact names in Task 5. `true_farm_uplift` (Task 2) is called as `dataset.true_farm_uplift(test_wtgs=..., masks=...)` in Task 5. `CampaignSpec.timing_for`/`usable_mask`/`mode`/`change_label`/`treatment_start` (Task 3) are consumed in Tasks 4–6 under those names. `CampaignResult` field names match between Tasks 5, 6 and 7. `per_turbine_table` is defined in `runner.py` and imported by `report.py` and the tests. diff --git a/docs/superpowers/specs/2026-08-27-realistic-campaigns-design.md b/docs/superpowers/specs/2026-08-27-realistic-campaigns-design.md deleted file mode 100644 index 1c758ad0..00000000 --- a/docs/superpowers/specs/2026-08-27-realistic-campaigns-design.md +++ /dev/null @@ -1,180 +0,0 @@ -# Design — realistic whole-farm campaigns & self-configuring v1 methods - -**Date:** 2026-08-27 -**Status:** approved design; feeds a fresh issues list at `docs/v1/issues_campaigns.md` -**Branch of work:** new tranche (developed off the current `v1` line) - -## Problem - -The v1 platform can already inject a known uplift and score methods on synthetic -data, but exercising it is still very manual. `benchmarking/baselines/inspect_wake_steering_case.py` -is the state of the art and it hard-codes turbine roles, hand-loads the northing -corrections, applies a script-level wind-direction-sector filter "hack", loops over -participants by hand, and bespoke-configures v0. Nothing about a campaign is -*declared*; it is all wired up by the driver. - -The next step to mature v1 is to **simulate a few realistic, whole-farm campaigns** -— modelled on the real Hill of Towie open-source analyses — and mature the v1 -methods so that a user **declares a short campaign spec and the method does the -right thing**: role assignment, northing, filtering, data split, reference -selection and reference validity, all automatically. Then we can see how the v1 -methods and v0 compare on realistic campaigns. - -## Scope & non-goals - -**In scope** -- Five realistic whole-farm campaigns, **one simulated instance each, no - replicates** (this tranche is about realism and ease-of-use, not sampling - statistics). -- Maturing the carried-forward methods so campaign behaviour is *declared*, not - hand-wired. -- A per-campaign comparison of v1 methods and v0 against the single ground truth. - -**Methods carried forward:** `oracle`, `naive_ratio`, `power_model`, -`toggle_specialist`. **`rlearner` is dropped completely** (see C7 — it is entangled -with `power_model` and needs disentangling first). - -**Explicit non-goals for this tranche** -- No replicate/ensemble statistics, no uncertainty-model development (the existing - `toggle_specialist` σ machinery is used as-is where it exists, not extended here). -- No new estimator research on `power_model`'s internals beyond what a campaign - demands. -- The pre-existing `docs/v1/issues.md` and `docs/v1/findings.md` effort goes on the - **back-burner**; its knowledge stays valuable for later phases. - -## Key decisions (from brainstorming) - -1. **Estimand: per-turbine estimates + a farm-level energy-weighted uplift.** Each - upgraded turbine gets its own uplift estimate and truth (as today), and each - campaign additionally yields **one headline farm number**, energy-weighted — - matching how the real HoT analyses report a single P50. For wake - steering the farm uplift naturally nets upstream steering losses against downstream - gains. - -2. **Two declarations, one source of truth.** A full **`SyntheticCampaign`** drives - the synthetic generator and includes the *secret* injected-upgrade physics (the - ground truth). The **`CampaignSpec`** is *derived* from it and carries only the - *public* facts a real analyst would have — upgraded turbines, upgrade timing, - mode (prepost/toggle), site coords + northing, candidate references, exclusions — - and **never the truth**. Methods only ever see the spec. - -3. **A campaign runner** turns a spec into results: for each upgraded turbine it - constructs the applicable carried-forward methods (skipping `toggle_specialist` - on prepost, etc.) and a per-turbine `MethodInput`, runs them, collects the - per-turbine `MethodOutput`s, computes the **farm uplift**, and emits both output - shapes. v0 is included in the comparison but optional (it is slow). - -4. **Both output shapes per campaign.** (a) A farm-wide **inspection report** — - per-turbine + farm-uplift tables and diagnostic plots vs the single ground truth, the - whole-farm analogue of `inspect_wake_steering_case` — as the human-facing - artefact; **and** (b) the single campaign wired through the existing harness - scoring path at **n=1** for a comparable, leaderboard-style number. - -5. **How the spec reaches the methods is deliberately left open.** Whether the - spec drives an orchestration layer above today's thin `MethodInput`/`MethodOutput` - seam, enriches `MethodInput` directly, or a hybrid, is its **own investigation - issue (C2)**, decided once the placebo pipeline exists (C1) and the demanding - campaigns have surfaced their real needs (northing, wake-free reference gating). - -## The five campaigns (in build order) - -Modelled on the three real Hill of Towie trials, plus a rated-power case and a -placebo: - -1. **Placebo** — whole farm, **zero injected uplift**. The starting point: it forces - the whole declaration → runner → farm-uplift → reporting pipeline into existence - on the simplest possible case, and is a real honesty check (every method, and - v0, must report ~0 with no false uplift). -2. **Blade enhancement (AeroUp)** — **prepost**, some upgraded turbine(s), - region-2 Cp gain tailing to 0 at rated. Forces automatic reference selection, - the prepost data split, and northing. -3. **TuneUp (controller)** — **toggle**, ~9 upgraded turbines, a TI/stability-shaped - effect. Forces multi-turbine toggle handling, the campaign-only split, and the - farm uplift at scale. -4. **Dynamic Yaw (wake steering + collective control)** — **toggle**, whole farm. - The hard one: inter-turbine wake dependencies, **references whose validity - changes with wind direction** (wake-aware reference selection / wake-free - gating), northing-sector logic, and excluded turbines (e.g. T07). Generalises - today's manual `inspect_wake_steering_case` hacks into *declared* behaviour. -5. **Rated-power up/downrate** — exercises the region-3 / rated-power path that none - of the others reach. - -## Architecture sketch - -``` -SyntheticCampaign (private: injected-upgrade physics = ground truth) - │ generate_dataset(...) - ▼ - SyntheticDataset ──────────────► true per-turbine & farm uplift - │ ▲ - │ derive │ score - ▼ │ - CampaignSpec (public facts only) ─► CampaignRunner - │ per upgraded turbine: - │ build method(s) + MethodInput - │ run → MethodOutput - ▼ - per-turbine results - │ energy-weighted - ▼ - farm uplift (headline) - │ - ┌──────────────────┴──────────────────┐ - ▼ ▼ - inspection report harness scoring (n=1) - (tables + plots vs truth) (leaderboard-style number) -``` - -- The **runner** owns method selection by mode, per-turbine input construction, and - the farm uplift; it is where "the method does the right thing" is orchestrated until - C2 decides how much of that moves onto the seam. -- **Farm uplift** is an energy-weighted aggregation of per-turbine uplift to a - single campaign number; the generator provides the matching farm-level ground - truth (upgraded-turbine synthetic energy vs counterfactual baseline energy over the - treated records). - -## Issue decomposition - -Tracked in `docs/v1/issues_campaigns.md`. Summary and ordering: - -- **C0 — Housekeeping (first, small, standalone).** Create the new issues doc; mark - the old `issues.md`/`findings.md` back-burnered with a pointer forward. -- **C1 — Campaign declaration + runner + farm uplift + placebo campaign.** The - foundation: `SyntheticCampaign`/`CampaignSpec`, the runner, the farm uplift, both - output shapes, all four carried-forward methods, v0 optional, on the placebo. -- **C2 — Seam / campaign-context decision.** The deferred architecture question, - decided using C1's experience and the later campaigns' needs. -- **C3 — Blade enhancement (prepost):** automatic reference selection + prepost - split + northing. -- **C4 — TuneUp (toggle, multi-turbine):** multi-turbine toggle + farm uplift at scale. -- **C5 — Dynamic Yaw (wake steering):** wake-aware reference validity + northing - sector + exclusions. -- **C6 — Rated-power up/downrate:** the region-3 / rated path. -- **C7 — Disentangle & remove `rlearner`.** `power_model/method.py` imports - `make_outcome_model` from `rlearner/nuisance.py` (and - `inspect_era5_matching_importance.py` does too; `era5_sync.py` was already - promoted out of rlearner). Relocate the outcome-model factory and any other shared - bits into `power_model`/a shared module, repoint the importers, then delete the - `rlearner` package, its tests, and the rlearner-only inspect scripts - (`inspect_prepost_feature_ablation.py`, the rlearner arm of - `inspect_era5_matching_importance.py`). Independent of the campaign work; schedule - any time. - -**Ordering:** C0 first → C1 foundation → C2 right after C1 → C3–C6 build on the -foundation → C7 slots in whenever. - -## Risks & open questions - -- **Seam shape (C2).** The thin seam is a deliberate design property today; enriching - it to carry campaign context trades that for methods that self-configure. C2 is the - place to weigh it, with real usage in hand. -- **Reference validity under wake steering (C5).** Declaring "candidate references" - is not enough when the upgrade itself changes wakes; the method must gate - references by direction. This is the least-understood piece and may itself spawn - follow-up issues. -- **Farm uplift weighting.** Energy-weighting is the obvious default; the exact - definition (per-turbine campaign MWh, counterfactual vs actual) is a C1 design - detail to pin against the generator's farm-level truth. -- **`rlearner` disentangle (C7).** The shared `make_outcome_model` must move without - changing `power_model` behaviour (the committed benchmark should stay identical). -``` diff --git a/docs/superpowers/specs/2026-08-28-c1-campaign-runner-placebo-design.md b/docs/superpowers/specs/2026-08-28-c1-campaign-runner-placebo-design.md deleted file mode 100644 index ce8834bd..00000000 --- a/docs/superpowers/specs/2026-08-28-c1-campaign-runner-placebo-design.md +++ /dev/null @@ -1,210 +0,0 @@ -# Design — C1: campaign declaration + runner + farm uplift + placebo campaign - -**Date:** 2026-08-28 -**Status:** USER REVIEW NEEDED -**Issue:** C1 in `docs/v1/issues_campaigns.md` -**Parent design:** `docs/superpowers/specs/2026-08-27-realistic-campaigns-design.md` - -## Problem - -The v1 platform can inject a known uplift and score methods, but exercising it is -hand-wired (`benchmarking/baselines/inspect_wake_steering_case.py` hard-codes roles, -loads northing by hand, loops participants manually). C1 stands up the whole -declaration → runner → farm-uplift → reporting pipeline on the simplest case — a -**placebo** (zero injected uplift) whole-farm campaign — so a campaign is *declared* -rather than wired, and every method is shown to report ~0. - -## Scope - -- A private, generator-facing `SyntheticCampaign` and the public `CampaignSpec` - derived from it. -- A `CampaignRunner` that turns a `CampaignSpec` + generated dataset into per-turbine results, - a farm uplift, and both output shapes. -- A pure `farm_uplift` headline function. -- Two placebo campaigns — one prepost, one toggle — declared once and run end-to-end. - -Out of scope: uncertainty/P95 (Phase 1 is P50-only); the seam/context decision (C2); -any new estimator research. - -## Key decisions - -### 1. Module split: analyst-usable → `src/`, benchmark-only → `benchmarking/` - -The test for `src/wind_up` (the product): a thing goes there only if it is purely -usable on real data with no synthetic-data or harness assumptions. - -- **`src/wind_up/farm.py`** — `farm_uplift(...)`. Pure: per-turbine - `(estimate, treated_energy, n_records, rated_power_kw)` → one headline number. No - toggle/synthetic/harness concepts. Depends only on numpy/pandas. -- **`benchmarking/campaigns/`** (new, benchmark-only, may import from `wind_up`): - - `declaration.py` — `SyntheticCampaign` (private; holds injected upgrades = ground - truth), `.generate()` → `SyntheticDataset`, `.spec()` → `CampaignSpec`. - - `runner.py` — `CampaignRunner`. - - `report.py` — the whole-farm inspection report (tables + plots vs truth). - - a concrete placebo driver (prepost + toggle), alongside the `inspect_*` scripts. - -`CampaignSpec` stays in `benchmarking/campaigns/` for C1. It is analyst-usable in -principle, but its `upgrade_timing` field references `ToggleSchedule`, which is **not** -cleanly `src`-ready: `ToggleSchedule` models a *perfectly regular* toggle (the -general real-data case is the seam's explicit `toggle_df`), and its "which timestamps -are on" semantics (`treated_mask`) live in `benchmarking/synthetic/generator.py`. -Promoting the spec and a settled toggle-timing type to `src` is deferred to W1/W2, -after C2 decides the seam/context representation — avoiding a second churn of the -toggle abstraction. - -### 2. `CampaignSpec` — public facts only - -Fields: `upgraded_turbines`, `upgrade_timing` (`pd.Timestamp` prepost / -`ToggleSchedule` toggle — mode read from `spec.mode`, not the type), `candidate_references`, -`excluded_turbines`, `coords` (wtg → (lat, lon)), `north_offsets` -(`(wtg, ts, offset)`), `rated_power_kw`, `analysis_period` (`(start, end)`), `turbine_col`. - -It carries **no** injected-upgrade physics. `SyntheticCampaign.spec()` derives it by -dropping the `upgrades`; a test asserts the spec exposes no upgrade magnitude. - -**Future-proofing for per-turbine change histories (C8).** These fields encode three -flat assumptions that real campaigns break: one changeover date shared by every -upgraded turbine (real aerodynamic upgrades are staggered over weeks), references that -are wholly usable or wholly excluded (a reference with its own recent upgrade is valid -for *part* of the period), and the word "upgrade" itself (some analyses confirm stable -performance or quantify a loss event). C1 keeps the flat model — generalizing it here -would swamp the issue — but must not let anything depend on its flatness. The field -shapes themselves are cheap to change later, since §1 keeps `CampaignSpec` in -`benchmarking/` rather than public API; the expensive thing would be consumer code -written against a single farm-wide date. So: - -- **Consumers ask per turbine, never read the flat field.** The runner, report and - methods go through `spec.timing_for(wtg)` and a `spec`-owned usable-records - accessor, not `spec.upgrade_timing` or a set-difference on `excluded_turbines`. In - C1 those accessors return the same answer for every turbine; in C8 only their bodies - change. -- **Mode is a spec property, not a type switch.** Expose `spec.mode` rather than having - callers `isinstance`-check `upgrade_timing` — per-turbine timing breaks that - inference, and the field is the thing C8 replaces. -- **One helper supplies the assessed-change label** used in report and plot titles. C1 - returns neutral text ("the change", "treated period") from it; C8 adds the optional - name and this stays a one-place change. - -### 3. Farm uplift — pooled energy ratio with estimated, guarded counterfactuals - -The truth is scoped to **treated records only** (post for prepost, on-blocks for -toggle), so pre-period relative energy is the wrong weight. The method cannot observe -the counterfactual post energy, so it estimates it from its own per-turbine uplift. - -Per upgraded turbine, over its treated records: - -- `Tᵢ` = actual observed treated-period energy (Σ finite active power); `Nᵢ` = count of - those records. -- Estimated counterfactual energy `Ĉᵢ = Tᵢ / (1 + ûᵢ)`, where `ûᵢ` is the method's - per-turbine P50. - -Guards on `Ĉᵢ` (bite only in perverse cases): - -1. **Capacity-factor cap** — implied mean counterfactual power `Ĉᵢ / Nᵢ` must not - exceed `rated_maxᵢ = max(pre_rated, post_rated)`; clip `Ĉᵢ = rated_maxᵢ · Nᵢ` if it - does. Catches `ûᵢ → −1` inflating the counterfactual. (Placebo: pre = post = - nameplate; C6's rated-change campaign supplies both.) -2. **Non-negativity floor** — floor `Ĉᵢ` at 0, dropping any turbine whose `ûᵢ < −1` - (negative counterfactual) or whose `Tᵢ` is negative, from the weighting. - -Headline (estimate side): `(Σᵢ Tᵢ) / (Σᵢ Ĉᵢ) − 1`. -Headline (ground truth): same shape, exact, from `original_df`: -`(Σᵢ synthetic treated energy) / (Σᵢ original counterfactual energy) − 1` — the -N-turbine generalization of the existing `true_net_uplift`. - -`farm_uplift` also returns the **per-turbine uplift spread** and a **guard-fired flag** -per turbine, so the "similar effect across turbines" assumption is checkable and any -clip/drop is visible rather than silent. - -Rationale (see `src/wind_up_v0/combine_results.py`): v0 uses -inverse-variance weighting for its ordinary fleet total and energy weighting for the -*net* (wake-steering) case (`calc_net_uplift`, weight `mean_power_pre`). Inverse-variance -needs a per-turbine σ that Phase-1 methods (`oracle`, `naive_ratio`, `power_model`) do -not produce, and the campaign headline question is "how much energy did the fleet -gain" — an energy-weighted quantity. Once a P95/σ model lands (a later phase), -`farm_uplift` can offer an inverse-variance variant. - -### 4. `CampaignRunner` - -Takes the `CampaignSpec` and the generated `SyntheticDataset`. For each upgraded -turbine: - -- Build the applicable carried-forward methods (skip `toggle_specialist` on prepost); - build a per-turbine `MethodInput` (synthetic subset over the analysis period, `test_wtg`, - `upgrade_timing`, `turbine_col`). The thin seam is kept; the runner orchestrates - (C2 revisits how much moves onto the seam). -- Run each method → collect `MethodOutput`s. - -Then: - -- Per-turbine truth via `dataset.true_uplift(mask=treated)`. -- **Estimate headline** via `wind_up.farm_uplift`; **truth headline** via the pooled ratio - from `original_df`. -- **n=1 harness rows** by wrapping the campaign as one `Replicate` + one - `CampaignWindow` spanning it and calling `score_one` per upgraded turbine — reusing - the harness's truth alignment, no new scoring code. - -**v0 is not run by the placebo.** It enumerates test/reference combinations per turbine, -which does not scale to a whole-farm campaign. The seam accepts it unchanged, so a later, -smaller campaign can include it. - -### 5. Two output shapes - -1. **Inspection report** — per-turbine + farm-uplift tables (estimate / truth / - signed_error per method), the per-turbine uplift **spread**, any **guard-fired - flags**, plus diagnostic plots (reusing `conditional_truth_vs_estimate` / - `plot_conditional_uplift`). The whole-farm analogue of `inspect_wake_steering_case`. -2. **n=1 harness number** — the tidy leaderboard-style frame from `score_one`. - -### 6. Placebo campaigns - -Two `SyntheticCampaign`s (one prepost, one toggle), ~6 HoT turbines, and `upgrades=[]` -(zero injected uplift → `synthetic == original` → truth = 0 by construction). Both start at -**2018-01-01** on **12 months of 2017 baseline**; the campaign length differs by mode, so the -`analysis_period` — the whole record the methods see, baseline and treated alike — does too. - -- **Prepost: a 12-month campaign** (2017-01-01 to 2019-01-01). Baseline and treated periods - then span the same twelve months of the year, so an unconditioned method cannot mistake a - seasonal difference between the periods for an effect. A shorter post period leaves exactly - that confound, and the placebo reads several percent away from zero. -- **Toggle: a 6-month campaign** (2017-01-01 to 2018-07-01), alternating in **50-minute - blocks** (a 100-minute `ToggleSchedule` period, whose halves are the blocks). Toggle needs - no seasonal matching: its on and off blocks interleave within whatever period it is given. - -A couple of turbines are nominally "upgraded", the rest are candidate references, one -excluded. No new no-op upgrade type is needed. - -## Architecture - -``` -SyntheticCampaign (benchmarking; private: injected upgrades = ground truth) - │ .generate() .spec() - ▼ ▼ -SyntheticDataset ──► true per-turbine CampaignSpec (benchmarking; public facts) - │ & farm uplift │ - └──────────────┬───────────────────────┘ - ▼ - CampaignRunner (benchmarking) - per upgraded turbine: build method(s) + MethodInput → MethodOutput - per-turbine truth; wind_up.farm_uplift (src) for the estimate headline - │ - ┌───────────┴────────────┐ - ▼ ▼ -inspection report score_one at n=1 -(tables + plots vs truth) (leaderboard-style rows) -``` - -## Testing - -- Unit tests for `farm_uplift` guards: `ûᵢ → −1`, `ûᵢ < −1`, implied CF > 100%, - negative `Tᵢ`; and the normal (no-guard) case reproducing the pooled ratio. -- `SyntheticCampaign.spec()` exposes no upgrade physics. -- A fast placebo run on a **tiny synthetic fixture** (not the full HoT download) - asserting every method's per-turbine and farm-uplift estimate is ~0 within tolerance, in - both modes, and that both output shapes are produced. - -## Done when - -Each placebo campaign is declared once and run end-to-end; every method's per-turbine -and farm-uplift estimate is ~0 within tolerance (both modes); the inspection report and -the n=1 harness number are both produced. `poe all-fast` green. diff --git a/docs/superpowers/specs/2026-08-28-robustness-failure-modes-design.md b/docs/superpowers/specs/2026-08-28-robustness-failure-modes-design.md deleted file mode 100644 index a0872b43..00000000 --- a/docs/superpowers/specs/2026-08-28-robustness-failure-modes-design.md +++ /dev/null @@ -1,124 +0,0 @@ -# Design — robustness to real-world failure modes (widening the campaigns tranche) - -**Date:** 2026-08-28 -**Status:** approved design; extends -`2026-08-27-realistic-campaigns-design.md` and feeds new R-series issues in -`docs/v1/issues_campaigns.md` -**Branch of work:** the same realistic-campaigns tranche (developed off `v1`) - -## Problem - -The realistic-campaigns tranche (C-series) matures v1 so an analyst can *declare a -campaign spec and the method does the right thing*. That makes v1 realistic, but not -yet **robust**: real SCADA carries data pathologies that were never used to develop -v0 or the v1 methods, and `power_model` in particular has soft spots (it can key on -unstable per-turbine sensors, trusts its references, and assumes a fixed set of -signals). To be usable in the real world, wind-up must handle these on its own. - -This design **widens the tranche's ambition** from "simulate realistic campaigns" to -"**round out v1 so it is usable in the real world**" — two pillars: - -1. **Realism** — declare a campaign spec, the method self-configures (the existing C-series). -2. **Robustness** — wind-up handles, on its own, the data pathologies real SCADA - throws at it. We *synthesize* each failure mode with known ground truth, so we can - measure exactly how much it degrades a method and prove a fix closes the gap. - -## The four failure modes (R-series) - -Real pathologies wind-up must survive: - -- **R1 — Northing errors.** Turbine direction references carry **step changes** in - their offset over time (a recalibration, a sensor swap). Drifts are *not* simulated - here — steps only. -- **R2 — Unstable sensors.** Per-turbine anemometer and (to a lesser extent) - temperature channels are unstable over time — **both step changes and slow - drifts**, primarily on wind speed. Using them as ML features silently biases an - estimate across the baseline↔treatment boundary. -- **R3 — Invalid references.** A reference turbine develops its **own** performance - shift (degradation, curtailment change) unrelated to the tested upgrade, appearing - during the campaign — a common, biasing real situation. -- **R4 — Missing data.** Channels/turbines are absent for part or all of a campaign - (a reference offline, a signal missing), so the input no longer matches any - hardcoded feature list. - -## Key decisions (from brainstorming) - -1. **Success = invariance, not a race against v0.** For each fault the baseline is - `power_model`'s **own** error on the *clean* version of the fixture; the target is - that injecting the fault degrades that error by little - (`power_model`-under-fault ≈ `power_model`-clean). v0 is **skipped** where - possible (it is slow, and was not developed against these synthesized faults); an - optional one-off sniff is allowed, never the yardstick. - -2. **The fault must bite.** Every R-issue begins by calibrating the fault magnitude - until it **significantly** throws off `power_model` on the clean fixture. If it - does not bite, the invariance target is vacuous and there is nothing to fix. - Making the fault bite may take iteration and is part of "done". - -3. **Fix location follows how the concern is shared.** - - **Northing (R1) is a shared feature-engineering step** in the runner / - preprocessing, upstream of every method — every method benefits, and C3/C5 - inherit it instead of hand-rolling northing handling. - - **The other three (R2/R3/R4) are `power_model`-internal.** Reference selection - in particular is method-specific (v0 screens references one at a time; - `power_model` uses them all at once), so a reference-validity screen belongs - inside the method, not in a shared layer. - -4. **Isolated fixtures first, then re-verify on campaigns (hybrid).** Each fault is - developed on a **tiny purpose-built fixture** — one treated turbine + ~3 - references, a simple known AeroUp-shaped uplift — so the fault signal is isolated - and iteration is fast. The best faults are then re-injected into the relevant - whole-farm campaigns (R1/R3 ↔ C3/C5) as an in-context check. - -5. **Every fault is evaluated in both prepost and toggle.** The tiny fixture is run in - **both modes**, and the "bites" check is **per-mode**. A toggle campaign's rapid - on/off switching means a fault present in both the on and off periods can partly or - wholly **cancel**, so a fault that bites hard in prepost may bite little or not at - all in toggle. Mitigation is applied **where it bites**; where a fault does not bite - in toggle, that is **documented as "no mitigation needed there"** — determined - empirically, never assumed. - -6. **Naming.** A distinct **`R1–R4`** ("robustness") series alongside `C1–C7`, - deliberately **not** `F#` (that collides with `findings.md`). - -## Sequencing (Approach A: robustness-first, after the foundation) - -``` -C1 → C2 → [ R1 R2 R3 R4 ] → C3 → C4 → C5 → C6 (C7 already done) -``` - -- C1 (declaration + runner + fixture plumbing) and C2 (seam decision) land first — - the R-fixtures reuse that plumbing. -- **R1 (northing) lands before C3** so the prepost campaign inherits the shared - northing step; R2–R4 are independent `power_model` work and can run in any order - within the block. -- C3–C6 then re-verify the relevant faults in-context (R1/R3 with C3/C5). - -Alternatives considered: **(B) interleave** each fault next to its nearest campaign — -less rework than campaigns-first but scatters the robustness story; **(C) -campaigns-first, robustness after C6** — clean separation but C3/C5 build throwaway -northing handling that R1 then replaces. (A) front-loads the one genuinely shared fix -(northing) and matches the isolated-first/hybrid decision. - -## Per-issue acceptance shape - -Every R-issue carries a two-phase **Done when:**, evaluated in **both prepost and -toggle**: - -1. **Bites (per-mode):** the fault demonstrably and significantly throws off - `power_model` on the clean tiny fixture, calibrated per-mode (not assumed). A fault - that does not bite in a mode needs no mitigation there — recorded explicitly. -2. **Fixed:** where it bites, the fix restores `power_model`-under-fault ≈ - `power_model`-clean; for R1, C3/C5 additionally drop bespoke northing wiring in - favour of the shared step. - -## Risks & open questions - -- **Making faults bite realistically.** The fault must be strong enough to break the - method yet plausible as real SCADA. Calibration is per-fault and may need iteration. -- **Reference-validity screen scope (R3).** Detecting a bad reference inside - `power_model` (which uses the whole pool at once) is less understood than v0's - one-at-a-time round robin; it may spawn follow-up work. -- **Missing-data surface (R4).** "Adapt to available signals" spans feature discovery, - partial windows, and graceful degradation; keep the tiny fixture minimal so the - requirement stays crisp. diff --git a/docs/superpowers/specs/2026-08-28-w0-src-layout-rename-design.md b/docs/superpowers/specs/2026-08-28-w0-src-layout-rename-design.md deleted file mode 100644 index edba04e2..00000000 --- a/docs/superpowers/specs/2026-08-28-w0-src-layout-rename-design.md +++ /dev/null @@ -1,154 +0,0 @@ -# W0 — `src/` layout + rename legacy to `wind_up_v0` - -**Status:** design approved 2026-08-28. Branch: `v1-W0`. - -## Goal - -The new v1 tool claims the `wind_up` import name while the legacy tool is retained -under `wind_up_v0`. Done **early** so all later v1 code lands in the new layout. This -is a behaviour-preserving restructure: the legacy pipeline must produce byte-identical -results before and after. - -## Sequencing (read first) - -**The example baseline MUST be captured BEFORE ANY change is made** — before moving any -file, before touching a single import, before editing `pyproject.toml`. The very first -action of implementation is to run the runnable examples on the pristine, unchanged tree -and save their outputs (see Done-when #6). If any change lands before the baseline is -captured, the baseline is invalid and the whole equivalence check is worthless — in that -case `git stash` / reset back to a clean tree and capture it first. Every other step in -this spec happens only after the baseline exists. - -## Target layout - -``` -wind-up/ - src/ - wind_up/ # v1 — new skeleton (claims the import name; W1 fills it) - __init__.py # package docstring + __version__ = version("res-wind-up") - py.typed - wind_up_v0/ # legacy — verbatim move of today's wind_up/ - benchmarking/ # unchanged location (stays OUT of src/) - tests/ # unchanged location - examples/ # unchanged location - config/ input_data/ cache/ # PROJECTROOT_DIR targets; stay at root for W0 -``` - -Distribution name **stays `res-wind-up`**; only import names change (`wind_up` = v1, -`wind_up_v0` = legacy). - -## The rename rule - -A blanket token rename **`wind_up` → `wind_up_v0`** applied to every reference to the -**legacy package**: - -- internal imports inside the moved package (~40 files under `src/wind_up_v0/`); -- `benchmarking/` importers: `baselines/v0_binned.py`, `baselines/hot_context.py`, - `synthetic/sources/hill_of_towie.py`, `synthetic/upgrades.py`; -- `examples/`: `kelmarsh_kaggle.py`, `smarteole_utils.py`, `wedowind_example.py`, - and `smarteole_example.ipynb`; -- `tests/`: every importer, `tests/conftest.py`, and `tests/test_data/hot/*`; -- the self-import at `main_analysis.py:13` (`import wind_up` → `import wind_up_v0`) - and the bare-attribute usages in `tests/test_wedowind.py` and `tests/test_version.py`. - -**Two things that must NOT change** (behaviour-preserving): - -1. The output dict **key** `"wind_up_version"` in `main_analysis.py` — it is part of - v0's result schema. Only the `wind_up.__version__` expression feeding it changes; - the value (same `res-wind-up` version string) is identical. -2. Prose / comments that name "wind_up" as the tool stay as-is unless actively - misleading. No churn on documentation wording. - -## Path-root fix - -`src/wind_up_v0/constants.py`: `Path(__file__).parents[1]` → `parents[2]` for the -repo-root-relative constants (`PROJECTROOT_DIR`, `CONFIG_DIR`, -`TURBINE_DATA_DIR`, `REANALYSIS_DIR`, `TOGGLE_DIR`), so they keep resolving to the -repo root after gaining the `src/` level. `OUTPUT_DIR` (`Path.home() / ...`) is -unaffected. This keeps `tests/test_version.py`'s `PROJECTROOT_DIR / "pyproject.toml"` -and `tests/conftest.py`'s `CACHE_DIR = PROJECTROOT_DIR / "cache"` working. - -## v1 skeleton - -`src/wind_up/__init__.py` = package docstring + `__version__ = version("res-wind-up")`; -plus `src/wind_up/py.typed`. This keeps `tests/test_version.py` -(`import wind_up; wind_up.__version__ == pyproject version`) green unchanged, since -both packages share the `res-wind-up` distribution version. W1 fills the real composed -method. - -## Packaging & config repointing (`pyproject.toml` and CI) - -- `[tool.setuptools.packages.find]`: `where = ["src", "."]`, - `include = ["wind_up*", "wind_up_v0*", "benchmarking*"]`. `src/` (no `__init__.py`) - is not itself a package, so find yields `wind_up`, `wind_up_v0` from `src/` and - `benchmarking` from the root without duplication; `tests`/`examples` are excluded by - not matching `include`. Rewrite the existing comment to explain `benchmarking` is - still packaged **temporarily** (external `toggle_specialist` use) and point at the - W2 cleanup. -- poe `test` / `test-fast` coverage source: `--source wind_up` → `--source wind_up_v0` - (coverage stays scoped to the legacy code, as today; skeleton/benchmarking uncovered). -- `[tool.coverage.report] omit`: `wind_up/plots/*.py` → `src/wind_up_v0/plots/*.py`. -- ruff `[tool.ruff.lint.per-file-ignores]`: repoint the `wind_up/...` keys to - `src/wind_up_v0/...` (`models.py`, `smart_data.py`, `plots/*.py`). The `**/__init__.py`, - `tests/**`, and `examples/**` globs are unchanged. -- mypy: `mypy .` names src modules correctly (it walks up from a file and stops at - `src/`, which has no `__init__.py`, yielding `wind_up_v0.*`); no config change - expected. Verify during implementation; if needed, add `mypy_path = "src"`. -- `.github/CODEOWNERS`: `wind_up/*` → `src/wind_up_v0/*` (and add `src/wind_up/*`). -- CI workflows drive everything through `poe` tasks and a generic `build`; no hardcoded - `wind_up` paths, so no workflow edits beyond the above. -- Reinstall the editable env (`uv sync`) after the move so both import names resolve. - -## Examples - -- **Runnable examples** (at minimum `smarteole_example.ipynb`; also - `kelmarsh_kaggle.py`, `wedowind_example.py` if their data / network is available): - refresh the import paths to `wind_up_v0` and **re-execute** so the stored notebook - outputs are regenerated against the renamed package. These land in the working tree - for the user to commit. -- **Non-runnable examples** (data no longer available, network required, etc.): do not - attempt to force-run. Add a short docstring / top-of-notebook note stating the example - is not currently runnable and a brief reason. The user decides later what to do with - them. - -## benchmarking + deferred cleanup (recorded on W2) - -benchmarking **stays packaged for now** (external `toggle_specialist` dependency). Add -a scope bullet to **W2** in `docs/v1/issues_campaigns.md`: once that external dependency -is gone, - -- drop `benchmarking*` from packaging and confirm it is excluded from the v1.0.0 - release artifact; and -- delete the `config/`, `input_data/`, `cache/` root folders — legacy artefacts from - before env-vars / `Path.home()` were used — reworking `constants.py`'s path handling - accordingly (env vars / `Path.home()` instead of `PROJECTROOT_DIR`-relative). - -## Verification / done-when - -1. Repo builds and `poe all-fast` passes under the new layout. -2. `wind_up_v0` runs the legacy pipeline; its **committed benchmark is unchanged**. -3. No importer references the old `wind_up` path for the legacy tool. -4. `import wind_up` resolves to the new v1 skeleton and exposes `__version__`. -5. Runnable examples re-execute cleanly with refreshed imports; non-runnable ones carry - a docstring note explaining why. - -### Done-when #6 — one-off before/after example benchmark (NOT a test) - -A manual, one-off equivalence check. No new or committed test artefacts; scratchpad only. - -- **STEP ZERO — before any rename or config edit, on the pristine tree** (`git status` - clean of W0 changes): run each runnable example with `OUTPUT_DIR` pointed at - `/baseline/`. This must happen before the first file is moved. Capture, - per example: - - all saved figure PNGs, and - - a text file of stdout + the repr of any result dataframes / uplift numbers. -- **After** the rename + config changes, run the same examples with `OUTPUT_DIR` pointed - at `/after/`. -- Compare: - - **Numbers:** `diff` the captured text — must be empty (exact equality). - - **Plots:** load each `baseline/` vs `after/` PNG pair with PIL/numpy and assert the - **pixel arrays are equal** (metadata-insensitive; stronger than a human glance). - - **Human backstop:** open a couple of before/after PNG pairs directly to sanity-check, - and report which examples ran vs were skipped. -- Any non-identical pixel array or numeric diff is a real signal to investigate, not to - wave through — a pure import rename is expected to be exactly equal. diff --git a/docs/superpowers/specs/2026-09-01-c2-campaign-context-seam-design.md b/docs/superpowers/specs/2026-09-01-c2-campaign-context-seam-design.md deleted file mode 100644 index 952caeb1..00000000 --- a/docs/superpowers/specs/2026-09-01-c2-campaign-context-seam-design.md +++ /dev/null @@ -1,333 +0,0 @@ -# Design — C2: how campaign context reaches the methods - -**Date:** 2026-09-01 -**Status:** USER REVIEW NEEDED -**Issue:** C2 in `docs/v1/issues_campaigns.md` -**Parent design:** `docs/superpowers/specs/2026-08-27-realistic-campaigns-design.md` - -## Problem - -C1 landed `CampaignSpec` — the public facts of a campaign — but nothing reads most of -it. `benchmarking/campaigns/methods.py` reads `spec.mode` and `spec.rated_power_kw`; -`runner.py` reads the timing, `usable_mask`, `analysis_period` and `turbine_col`. -`candidate_references`, `coords`, `north_offsets` and `excluded_turbines` reach no -method at all. - -That is not an oversight, it is the deferred question: the seam is -`MethodInput(scada_df, test_wtg, upgrade_timing, turbine_col)`, and every method -derives its references the same implicit way — *every turbine in `scada_df` that is not -`test_wtg`* — in nine places across four methods. Reference selection is therefore -**enacted** by the runner subsetting the frame, never **declared**. - -The campaigns and failure modes queued behind C2 all need declared campaign facts: - -- **C3** must exclude other upgraded turbines and honour a candidate list. -- **C5** must drop a reference *for some wind directions only*, from declared geometry. -- **R1** needs coords and north offsets in the runner, for a shared step whose output - every method inherits. -- **C8** must use a reference over the part of the period its own change history allows. - -Row removal cannot express any of the time-varying cases. A complete-case method such -as `naive_ratio` loses the whole timestamp when one reference is gated, rather than -dropping just that reference; and a method cannot tell an enacted gate from R4's -genuine missing data. Validity has to be **told** to methods, not enacted on the frame. - -## Scope - -- A `CampaignContext` — the narrow, per-test-turbine view of a campaign that a method - may consult — and its default constructor. -- `MethodInput.context`, threaded from both input paths (`CampaignRunner` via - `score_one`, and the study path in `harness/scoring.py`). -- Every method sourcing its references and its row validity from the context. -- `ColumnSchema.northed(role)`, so R1 fills a derived column whose name it does not - have to invent. - -Out of scope, left to the issues that own them: the northing algorithm (R1), -wake-direction gating (C5), method-internal reference screening (R3), missing-data -adaptation (R4). C2 plumbs the channel; those issues fill it. - -## Key decisions - -### 1. Methods see a derived view, never the declaration - -`CampaignSpec` is the analyst's declaration. C8 generalizes it to per-turbine change -histories and W2 promotes it to public `src/wind_up` API. Coupling every method to it -means each of those churns reaches every method. - -So the runner **derives** a `CampaignContext` from the spec, per test turbine, and that -is what rides the seam. The context asks and answers questions — *which turbines may I -use as references? which rows may I use?* — rather than exposing declaration fields. C8 -then changes one translation function, and the study path can build a context with no -declaration at all. - -This also settles the layering: the context is part of the **method contract**, so it -lives in `benchmarking/harness/context.py` alongside the seam, while the derivation -lives in `benchmarking/campaigns/context.py`. `campaigns` already depends on `harness`; -the reverse dependency would be a cycle. - -### 2. Declared validity is shared; screened validity is method-internal - -| | Decided by | Lives | -|---|---|---| -| **Declared validity** — exclusions, per-turbine change histories (C8), geometry + direction wake gating (C5) | the campaign, identically for every method | runner-computed, rides the context | -| **Screened validity** — "this reference looks broken in the data" (R3) | the method — v0 screens references one at a time, `power_model` uses them all at once | method-internal | -| **Method configuration** — ERA5, model params, bin widths, `save_plots` | the method | constructor, as today | - -Declared validity is a fact about the campaign; methods should not each re-derive it -from geometry. Screened validity is a modelling choice and must stay method-internal, -as the robustness design already concluded. Conflating the two is what makes C5 look -hard. - -### 3. Validity is named for its purpose: `valid_for_uplift` - -The load-bearing member is a boolean frame, timestamps × turbines, answering *may this -turbine's data at this timestamp contribute to the uplift estimate?* - -It is deliberately **not** a general "usable" flag. Validity is purpose-specific: a -one-off curtailment ruins the turbine's performance but not its yaw alignment, so those -rows are invalid for uplift and perfectly valid for a northing analysis. Naming the -purpose means a second purpose arrives as a sibling frame (`valid_for_northing`, …) -rather than as a silent reinterpretation of one overloaded frame. C2 names only the -purpose it has. - -One frame serves C3 (which references), C5 (a reference in a steered wake is `False` -for those directions only), C8 (a reference valid for part of the period) and R4 — -and it lets each method reduce it its own way: `naive_ratio` can drop a reference -wholesale rather than lose the timestamp, `power_model` can NaN just that reference's -features, v0 can screen per reference. - -### 4. The frame is precomputed, not a callable - -`valid_for_uplift` is materialized by whoever builds the context, not computed lazily -on demand. C5's direction gating needs the SCADA to evaluate, and an accessor taking a -frame would give different answers for different sub-frames — the same campaign fact -must not depend on which slice a method happens to pass. The runner has the full -visible frame and computes once. - -The invariant is **coverage, not equality**: `valid_for_uplift.index` covers the unique -timestamps of `mi.scada_df`. Methods narrow it with `context.valid_over(index)`, which -raises on an uncovered timestamp. Coverage rather than equality is what lets -`replace(mi, scada_df=...)` — used by `restrict_to_campaign` in `naive_ratio` and -`toggle_specialist` — keep working untouched. - -### 5. The default context is the truth, not a fallback - -```python -CampaignContext.from_frame(scada_df, test_wtg=..., timing=..., turbine_col=...) -# candidate_references = every other turbine present in the frame -# valid_for_uplift = all True -``` - -This is today's implicit contract written down. It is *correct*, not degraded, so -methods have exactly one read path and never branch on `context is None`. The study and -sweep paths keep working with no `CampaignSpec` in sight. - -`MethodInput` keeps `upgrade_timing` and `turbine_col` as **constructor shorthands** -that build the default context when `context` is omitted; reading them goes through -properties delegating to the context. All ~100 existing construction sites in tests and -drivers keep working unchanged, and there is one source of truth at read time. - -`mode` comes from the context, closing the `isinstance` type-switch on `upgrade_timing` -that the C1 design already flagged as the wrong inference once timing is per-turbine. - -The context's field is `timing`, not `upgrade_timing`. C8 retires the "upgrade" -vocabulary — not every campaign is an upgrade, some confirm stable performance or -quantify a loss event — and C1 already began the move with -`CampaignSpec.change_label()`. `CampaignContext` is a new type, so naming its field -`upgrade_timing` would add one more use of the word C8 is committed to removing. The -qualifier is also unnecessary here: the context is per-test-turbine and describes -exactly one change, whereas on `MethodInput` the name floats free among unrelated -fields and needs it. `change_timing` is the near alternative, but the neutral term is -C8's decision to settle; `timing` stays correct whichever way C8 goes and needs no -later rename. So `mi.upgrade_timing` survives as a legacy delegating property, -`context.timing` is the forward name, and C8 renames the property and the spec field -without touching the context. - -### 6. Methods consume it now - -All four methods take reference *membership* from `context.candidate_references` and -honour `valid_for_uplift`, replacing the nine independent -`[c for c in wide.columns if c != test_wtg]` derivations -(`naive_ratio` ×3, `toggle_specialist` ×3, `power_model/features.py` ×1 shared by two -callers, `v0_binned` ×1). - -Doing this in C2 rather than declaring an unused type is the difference between -plumbing a channel and repeating C1's shape of shipping fields nothing reads. Under the -default context it is behaviour-identical by construction. - -**Methods keep their own reference ordering** and take only membership from the -context — `power_model` sorts, the others follow wide-column order. LightGBM feature -order depends on it, so changing it would move the frozen benchmarks. - -**`candidate_references` goes live in C2**, which is the one deliberate behaviour change. -The placebo declares six upgraded turbines (`T07, T11, T12, T06, T16, T19`) and a -`candidate_references` list that excludes them, yet today's implicit rule means -estimating `T07` uses the other five *upgraded* turbines as references. Honouring the -declaration drops them. C3 still owns automatic reference selection as a feature; what -C2 does is stop the declaration being ignored, because shipping the channel with -nothing flowing through it would repeat exactly the C1 shape this issue exists to fix — -and a reference that is itself upgraded biases every campaign that has real uplift. - -The consumption contract: - -- A turbine in the frame but not in `candidate_references` is not a reference, even - though its data is present. Such a turbine can still be valuable for feature engineering (eg waking state) -- A row where `valid_for_uplift[turbine]` is `False` contributes none of that turbine's - data to the estimate; each method applies it as a per-turbine mask, composing with - its existing availability and complete-case filtering. -- `valid_for_uplift[test_wtg]` being `False` excludes that timestamp entirely. - -## The type - -```python -@dataclass(frozen=True) -class CampaignContext: - test_wtg: str - timing: pd.Timestamp | ToggleSchedule | pd.DataFrame - turbine_col: str - candidate_references: list[str] - valid_for_uplift: pd.DataFrame # bool; index x [test_wtg, *candidate_references] - - @property - def mode(self) -> Literal["prepost", "toggle"]: ... - def valid_over(self, index: pd.DatetimeIndex) -> pd.DataFrame: ... -``` - -The context carries **only answers**. `coords`, `north_offsets` and `rated_power_kw` -are deliberately absent: no method reads the first two — R1's shared northing step and -C5's wake gating both run in the runner, which holds the `CampaignSpec` directly, and -v0 uses its own vendored YAML — while `rated_power_kw` already reaches -`PowerModelMethod` and `ToggleSpecialistMethod` through their constructors, and a -second channel for one fact is exactly the inconsistency this type exists to avoid. If -a method later needs geometry, a field plus a line in `context_for` adds it; a field -that sits unread across three issues drifts. - -R1's shared step writes a **derived column**, `northed_` (e.g. -`northed_YawAngleMean`), and leaves the original untouched. Correcting in place would -make every existing plot and diagnostic lie about what it shows — a chart labelled -`YawAngleMean` that is no longer `YawAngleMean` — and the original is still wanted, by -the northing diagnostics themselves and by R1's own fault injection. - -The derived name comes from the schema, not from string-building at each call site: -`ColumnSchema.northed(role)` returns `f"northed_{getattr(self, role)}"` and raises if -the role is unset, preserving the rule that methods take column names *only* from a -`ColumnSchema`. C2 adds that accessor; R1 fills the column. (The function that computes -the values already exists as `north_calibrated_direction` in -`benchmarking/synthetic/upgrades.py` — `northed_` is its short form as a column prefix, -so R1 should not coin a third term.) - -**Which turbine's direction it is decides whether it may be a feature.** Design-note -§3 bars the *test* turbine's own nacelle position: it is post-treatment, and northing -does not change that. It says nothing against a **reference** turbine's northed -direction, which is treatment-invariant in the same way reference power is — and is -plainly useful, since knowing where each reference is pointing is much of what resolves -who is waking whom. R1/W1 may well try it as a feature and this design does not -foreclose it. - -Two caveats travel with that. Reference *anemometer* signals (nacelle wind speed and -SD) were rejected as features on calibration drift; direction is a different signal and -northing is precisely the drift correction for it, which is what makes the northed -version a defensible candidate where the raw one is not. And under wake steering a -reference sitting in a changed wake stops being treatment-invariant — which is what -C5's gating is for, not a reason to bar the feature everywhere. - -There is **no `northing_applied` flag**. Whether the step has run is already written -in the frame — `columns.northed(role) in scada_df.columns` — and a flag beside it is -second state that can disagree with the thing it describes. A method that needs -northing checks for the column and raises naming it, which is both accurate by -construction and a better error. v0 needs no guard either way: it does its own northing -from its vendored YAML and never reads the derived column, so the double-correction -risk that in-place correction would have carried does not arise. - -## Architecture - -``` -CampaignSpec (analyst declaration; C8 generalizes, W2 promotes to public API) - │ - │ campaigns/context.py: context_for(spec, turbine=..., scada_df=...) - ▼ -CampaignContext (harness/context.py; the method contract) - │ candidate_references / valid_for_uplift / timing / mode - ▼ -MethodInput(scada_df, test_wtg, context) ─────► every method - -Study path: Replicate ──► CampaignContext.from_frame(...) (no declaration needed) -``` - -`CampaignRunner` derives one context per upgraded turbine and passes it to `score_one`, -which forwards it to `_method_input` instead of building the default. This is where -`candidate_references`, `excluded_turbines` and `usable_mask` stop being dead fields. - -## Acceptance: the frozen benchmarks must read UNCHANGED - -**This is the acceptance criterion for C2, not a nice-to-have.** C2 changes no -estimator mathematics whatsoever — it changes *where a method learns which turbines to -use*. Every existing path builds the default context, whose `candidate_references` is -every other turbine and whose `valid_for_uplift` is all `True`, so every number must -come out where it is today. **Any movement in a frozen benchmark is a bug in the -re-plumbing, not a result.** - -**Scope of the claim: the study path.** Both frozen benchmarks are driven by -`score_study` over replicates and profiles, which builds the *default* context, so they -must not move at all. The **campaign** path does move, by design and only where the -declaration was previously ignored (§6): the placebo's per-turbine and farm numbers -change because the other upgraded turbines leave the reference set. That makes CF1-CF5 -in `findings_campaigns.md` stale; re-recording them is not part of C2 and must be -flagged rather than quietly left. - -Two committed benchmarks, both diffed on the machine that recorded them: - -| Benchmark | Command | Required verdict | -|---|---|---| -| `benchmarking/baselines/study_power_model_compare_baseline.json` | `uv run python -m benchmarking.baselines.study_power_model_compare --reference-dir "~/temp/wind-up-benchmarking/badass overnight 20260708"` | `UNCHANGED` | -| `study_toggle_methods_compare_baseline_{portable,linux,win32}.json` | `uv run python -m benchmarking.baselines.study_toggle_methods_compare` | `UNCHANGED` | - -**Never re-record.** `--update-baseline` and `--accept-candidate` are forbidden for the -whole of C2. Those flags exist to accept a deliberate improvement; C2 has no -improvement to accept, so reaching for them would be recording a bug as the new truth. -If a benchmark moves, fix the code. - -**`toggle_specialist` is the sharp instrument.** Its reproducibility band is `1e-7` -(`_REPRODUCIBILITY` in `study_toggle_methods_compare.py`) because it is pure arithmetic -that reproduces exactly; a genuine behaviour change shows there unambiguously. -`power_model`'s band is `1e-3` — its ~0.05 pp same-machine LightGBM noise could mask a -small real change, so a clean `power_model` verdict alone is not sufficient evidence. - -**Run both before touching any method**, to confirm the working tree reads `UNCHANGED` -on this machine to start with. Without that pre-check, a pre-existing drift gets -misattributed to C2 — and conversely a `MOVED` afterwards can be pinned on this work -with confidence. Both benchmarks are machine-specific for `power_model` (~0.7 pp false -`MOVED` cross-machine, 14x the same-machine noise), so this must be the Linux laptop -the committed files were recorded on; a cross-machine excuse is not available here. - -The two things that could break this, both guarded in §6: methods must keep their **own -reference ordering** (LightGBM feature order depends on it — take only *membership* -from the context), and the default context must remain exactly today's implicit -contract rather than a subtly different one. - -## Testing - -- `CampaignContext.from_frame` reproduces today's implicit contract: every other - turbine is a candidate reference, everything valid. -- `valid_over` raises on an index carrying a timestamp the context does not cover. -- Per method, with a hand-built non-default context: a reference excluded from - `candidate_references` contributes nothing; a reference marked invalid over part of - the period contributes nothing over that part and still contributes over the rest; - a timestamp where the test turbine is invalid drops out entirely. -- `ColumnSchema.northed` derives the prefixed name from a set role and raises on an - unset one. -- `context_for` exposes no upgrade magnitude, mirroring the existing `CampaignSpec` - truth-leak test — the runner stays the single audited place where truth could leak. -- The placebo campaign still reports ~0 for every method in both modes. - -## Done when - -Every method takes its references and row validity from `CampaignContext`; both input -paths supply one; `poe all-fast` green; and **both frozen benchmarks read `UNCHANGED`, -re-recorded neither by `--update-baseline` nor `--accept-candidate`** (see -[Acceptance](#acceptance-the-frozen-benchmarks-must-read-unchanged)). C2 is pure -re-plumbing, and the benchmarks are how that claim is proved rather than asserted. - -C2 also amends its own **Done when** in `docs/v1/issues_campaigns.md`, which currently -promises a decision the code reflects across the board; the northing, gating, screening -and missing-data work it hints at belongs to R1, C5, R3 and R4. diff --git a/docs/superpowers/specs/2026-09-02-r1-northing-design.md b/docs/superpowers/specs/2026-09-02-r1-northing-design.md deleted file mode 100644 index 126d4fb4..00000000 --- a/docs/superpowers/specs/2026-09-02-r1-northing-design.md +++ /dev/null @@ -1,851 +0,0 @@ -# Design — R1: northing errors (shared fix) - -**Date:** 2026-09-02 -**Status:** approved design -**Issue:** `docs/v1/issues_campaigns.md` § R1 -**Extends:** `2026-08-28-robustness-failure-modes-design.md` (R-series ground rules), -`2026-09-01-c2-campaign-context-seam-design.md` (the seam decisions R1 fills in) -**Branch of work:** `v1-R1`, developed off `v1` - -## Problem - -A turbine's direction reference carries **step changes** in its north calibration — -a recalibration, a sensor swap, a controller replacement. wind-up must recover a known -uplift regardless. - -Two things stand in the way today. - -**The estimator is too slow to use.** `src/wind_up_v0/optimize_northing.py` (762 lines) -hill-climbs over a hand-rolled move set — shift a changepoint forward, shift it back, -add *n* changepoints via `ruptures.BottomUp` with a custom circular L1 cost — rescoring -the whole turbine on every move, with a step size that decays and re-inflates through -a `1/(DECAY_FRACTION ** (pi * (tries_left + 1))) % 10` schedule. It is slow enough that -it is switched off in practice: both `examples/` set `optimize_northing_corrections=False` -and use pre-computed tables, and the benchmarking layer reads a vendored -`optimized_northing_corrections.yaml` (the result of a prior run of `optimize_northing.py`). - -**No v1 method can see a northing error.** `benchmarking/baselines/power_model/features.py` -builds features from reference active power, availability and ERA5 — no turbine direction -signal of any kind. A step injected into `YawAngleMean` is invisible to it, so the -R-series' phase 1 ("the fault bites") is unreachable until the feature exists. - -## Scope - -All four parts land together, in this order: - -1. a fast, `ruptures`-free northing estimator in `src/wind_up/northing.py`; -2. a shared northing step in the campaign runner, upstream of every method, plus the - `power_model` reference-direction feature that makes a northing error visible; -3. a fault injector and the tiny fixture that proves *bites* then *fixed*; -4. `src/wind_up_v0/optimize_northing.py` reduced to a thin adapter over the new core, - with its tests ported and `ruptures` dropped from `pyproject.toml`. - -`naive_ratio` and `toggle_specialist` use no direction signal and are out of scope. - -## Key decisions - -1. **Exact dynamic programming, not a numerical optimiser.** Locating step changes is - combinatorial; a continuous optimiser has nothing to descend, which is the fragility - the current hill-climb exhibits. Given the segments, each offset is closed-form (a - circular median). So the estimator contains no optimiser at all. - -2. **Aggregate before searching.** The residual is piecewise-constant plus noise, so a - daily circular median loses nothing a changepoint search needs and turns 105k rows - (2 years at 10 minutes) into ~730 points. This is what makes the search cheap; local - refinement recovers sub-day changepoint timing afterwards. - -3. **The core is frame-agnostic and device-neutral.** It takes an index, a direction - array, a reference-direction array and a caller-supplied `usable` mask. Turbine-specific - logic (generating above 5% of rated, not in downtime) lives in a helper, not the core, - so masts and LiDARs are a mask away rather than a rewrite. - - **`usable` is also how wake steering is handled.** A steering turbine is deliberately - yawed off the wind, so a steered period looks exactly like a northing offset that appears - and disappears on the steering schedule. Excluding those rows via the mask is the whole - fix, and it needs no change to the core — which is a second reason to keep mask - construction outside it. C5 supplies the steered-period mask; R1 only has to not - foreclose it. - -4. **Knobs are in physical units.** `min_step_deg` (the smallest step worth reporting) - and changepoints-per-year, not sample counts or pruning constants. See the prior-art - review below for why this is worth insisting on. - -5. **The returned table is always absolute** — offsets relative to the **raw** field, - never "further corrections to an already-corrected field". This is what makes a supplied - table and an estimated one directly comparable, and repeated runs composable. See - *Designed for, not implemented*. - -6. **Two passes are preserved.** Pass 1 norths each turbine to reanalysis wind direction; - the northed yaws give a farm direction; pass 2 norths to that. Pass 2 is far less noisy, - but pass 1 is what anchors the farm in absolute terms — without it a farm that is - uniformly 180° wrong looks perfectly self-consistent. - -7. **The fault is a measurement corruption, not an upgrade.** It changes direction only, - never power, so ground truth is untouched by construction. - -8. **Success is invariance**, per the R-series ground rules: the target is - `power_model`-under-fault ≈ `power_model`-clean, not a race against v0. - -9. **How small a step may be attributed depends on the reference.** Reanalysis is a modelled - direction carrying its own drift, and a shift in it is indistinguishable from every turbine - shifting at once — so against reanalysis only steps above `REANALYSIS_MIN_STEP_DEG` (10°) - are attributable, whatever tier the caller chose (`against_reanalysis(effort)`). A farm - consensus shares that common-mode error, so against one a residual step really is that - turbine's and the caller's `min_step_deg` applies. Where the farm reference has fallen back - to reanalysis (fewer than three turbines) the second pass stays conservative too. This is - what v0 was already expressing as `best_score_margin=0.5` on its reanalysis pass. Not - foreseen in design — forced by the measurement in *Evidence* below. - -## Evidence: reference-side drift is real and had to be designed for - -Measured on the Homer July-2023 fixture while porting the v0 tests. Against reanalysis, both -turbines appeared to step on **2023-07-12 within 40 minutes of each other** -- T01 by 3.8 -degrees, T02 by 5.0. Two independent sensor recalibrations on the same afternoon is not -credible, so the shared signal was isolated by comparing the turbines with **each other** -instead of with reanalysis: - -| comparison | before 07-12 | after 07-12 | step | -|---|---|---|---| -| T01 - reanalysis | 55.80 | 52.00 | **-3.80** | -| T02 - reanalysis | -118.40 | -123.30 | **-4.90** | -| T01 - T02 (no reanalysis) | 174.00 | 175.00 | **+1.00** | - -About 4 degrees of the apparent step is common-mode: it is in the reanalysis reference, not in -the turbines, whose relative alignment barely moved. An estimator that attributes it to the -turbines produces two spurious changepoints and shifts the northing by ~1.7 degrees. - -`REANALYSIS_MIN_STEP_DEG = 10` is sized from this: about twice the measured common-mode drift, -and far below a real sensor swap (the ported test injects 30-degree steps, and a north -recalibration is a large move by nature). It is a floor on the threshold, not a new tier, so a -caller asking for `thorough` still gets 1-degree resolution against the farm consensus. - -The measurement agrees with the physics: **reanalysis is not accurate to better than ~10° as a -representation of the wind direction at a specific turbine's hub height.** It is a coarse-grid -modelled field, not a measurement at the rotor. So attributing a sub-10° step to a turbine on -reanalysis evidence alone is asking the reference for precision it does not have, whatever any -one fixture shows. - -This is the same failure HOGER's >50% pairwise-consensus vote addresses, reached from the other -direction -- which is why pairwise consensus stays the recorded next step rather than a -speculative one. - -## Evidence: site veer, and why a threshold alone cannot fix it - -Comparing the first implementation's north tables against the vendored Hill of Towie table (the -old optimizer's output, and the only "old" answer we need — it need not be re-run) exposed a -second, larger problem than the reanalysis drift above. - -**84 changepoints in 2017–18 against the old table's 7.** T06 alone got 12, sitting exactly on -its `6/year × 2` budget ceiling. Its offsets oscillated between ≈ −6° and ≈ −13° and **netted -0.38° across all 12 steps** — ending where they began. A recalibration is permanent; this was an -excursion being approximated by a square wave. - -The cause is **site veer**: the wind direction genuinely differs from turbine to turbine across a -site, varying with bulk direction, atmospheric stability and wind speed. So a turbine's residual -against the farm median has a level that depends on *which directions the wind blew from*, and a -shift in the direction mix moves that level with nothing at the turbine having changed. The -spurious-jump count tracks the veer amplitude exactly, with the cut falling on -`balanced`'s 3° threshold: - -| turbine | monthly-residual range | spurious jumps | -|---|---|---| -| T06 | 4.7° | 12 | -| T09 | 3.5° | 10 | -| T15 | 3.4° | 8 | -| T07 | 2.0° | **0** | -| T11 | 1.3° | **0** | - -Raising `min_step_deg` was measured and rejected as the fix. It works, but only by trading away -real detections — at 10° the worst northing error jumps to 7.9° (a genuine step between 7° and -10° goes unfound), and veer amplitude is turbine-specific, so one global threshold is either too -loose for T06 or too tight for T11. **The tiers were left at the user's original 5 / 3 / 1°** and -the cause attacked instead, by two mechanisms. - -### 1. Veer normalisation (`veer_normalised`) - -Subtract each direction sector's own whole-record median from the residual before searching. A -genuine north offset shifts every sector alike and survives; a change in the direction mix cannot -move the level at all. 30° sectors by default (20° measured no better). - -Two details make it correct rather than circular: - -- **Detection only.** Segment offsets are estimated from the *raw* residual, so the correction - stays absolute and the 1°-accuracy goal is untouched. -- **De-step first.** Measuring sector levels on a record that contains large steps lets the steps - leak into the veer signature, and uneven direction sampling between segments then distorts the - very steps being looked for — this broke the ported Homer changepoint test outright. So a first - pass detects on the raw residual purely to remove the step structure, the veer signature is - measured on that de-stepped residual, and the real search runs on the normalised one. - -### 2. Ironing out self-cancelling excursions (`_prune_transient_steps`) - -After detection, drop changepoints that do not *persistently* move the level: for each one, -compare the duration-weighted level of everything before it with everything after. Veer wanders -away and back, so either side sits at the same place; a recalibration leaves the level moved. - -**With an amplitude gate, which is essential.** A first cut without one pruned by persistence -alone and sent the worst northing error to 17°, because real recalibrations *do* sometimes -reverse: the vendored table has T16 stepping 98°, 9°, 7°, 89° for a net of only +11.4°. So a step -above `max_transient_step_deg` (10°) is never ironed out — its size is the evidence it happened. -Below that, the same table shows T11 (four steps of 2–9°, net +0.3°) and T10 (8.2°/9.6°, net -+1.5°), which look exactly like the veer the filter is meant to remove. - -It is a threshold rule, not an oracle: an oscillation biased enough that its halves sit at -genuinely different levels keeps the changepoints carrying that difference (a unit test pins -this, so the limit is documented rather than discovered later). - -### Measured effect - -Farm-scale, Hill of Towie, 21 turbines, 2017–18, at the unchanged `balanced` 3° threshold: - -| configuration | jumps | turbines | worst err | -|---|---|---|---| -| first implementation | 84 | 19 | 3.665 | -| + veer normalisation | 51 | 11 | 3.471 | -| **+ excursion pruning (shipped)** | **8** | **4** | 5.125 | -| *old v0 (vendored table)* | *7* | *3* | *5.121* | - -**Mean error is deliberately not in that table.** It is *best* on the 84-jump row, so it rewards -over-detection and cannot discriminate — more free parameters always fit better. Jump count -against the real rate (~0.3/turbine/year, from the vendored table's 49 entries over 8 years and -21 turbines) and worst-case error are the honest measures. - -The decisive result is not the totals but **which** turbines. The shipped estimator finds -`{T01: 2, T05: 2, T13: 1, T16: 3}`; the vendored v0 table's 2017–18 changepoints are -`{T01: 2, T05: 2, T16: 3}`. It **independently rediscovers v0's changepoints exactly** — same -turbines, same counts — and adds one on T13, whose worst-case error improves 3.85° → 3.67°. - -Honest attribution: **the excursion pruning does most of the work**; direction binning contributes -5–10%, and is kept because it is physically right and cheap, not because it carries the result. - -On the fixture the effect is starker still: the clean arms now discover **0** changepoints and the -faulted arms exactly **1** — the injected fault and nothing else, where the first implementation -found 7–11 spurious ones per run. - -## Evidence: what the record's edges do, and what an outage does - -Two further artefacts, both found by comparing against v0's published table on Hill of Towie -rather than by reasoning. - -### The edge artefact (fixed) - -The estimator reported a +3.5° 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; every window not ending there is clean. - -| window | T13 changepoints | -|---|---| -| 2016-01-01 → 2018-01-01 | none | -| 2017-01-01 → **2019-01-01** | **2018-12-20, +3.36°** | -| 2017-01-01 → **2019-01-03** | none | -| 2017-01-01 → 2019-08-17 | none | - -A step in the data does not care where the record happens to stop, so this is the estimator, not -the turbine. The cause is the persistence test: with twelve days after it, the "after" level is a -veer-dominated estimate that came out 3.36° from the "before" level, scraping over the 3.0° -threshold. - -**The fix is to scale the required step with the record supporting it.** A segment's level is -limited by site veer rather than by sampling noise, and veer averages out no faster than -`1/sqrt(span)`, so: - -``` -required = clip(min_step_deg * sqrt(confident_segment / span), min_step_deg, max_transient_step_deg) -``` - -with `span` the shorter side of the changepoint and `confident_segment` 90 days — roughly the -record needed to average over veer's monthly wander. - -A flat "near an edge, demand more than 10°" rule was tried first and **rejected because it broke a -real detection**: T16's genuine +9.0° step on 2017-06-18 has only 30 days before it and 52 after. -The scaled rule requires 5.2° there and keeps it, requires 8.2° at T13's twelve days and drops it. -The cap at `max_transient_step_deg` keeps a large late jump findable, which a unit test pins. - -### The outage artefact (fixed) -- and it was in the first pass all along - -Over 2016-2024 nearly every turbine gained changepoint pairs at **2019-11-11/19**, **2020-06-12/19** -and **2023-06** -- steps of 12-22 degrees, farm-wide, synchronous, self-cancelling within about -eight days. **143 changepoints against v0's 28**, with 111 of the 127 extras in just three months. -They survived the excursion filter because their size is above `max_transient_step_deg`. - -Three hypotheses were tested and two were wrong, which is worth recording because each looked -convincing: - -1. **Silent reference substitution.** v0's `add_wf_yawdir` fills a missing farm direction with - reanalysis, and in the excursion weeks 35% and 51% of rows are that fallback. Excluding them - removes the August 2020 pair and **nothing else**. -2. **Changing reference composition.** Plausible -- turbines have different veer, so a median over - a shrinking subset should drift. Measured and **false**: after the first pass every device's - long-run offset from the farm median is within -0.3 to +0.5 degrees, and centring the devices - moves the median by 0.00 degrees in every window. -3. **The first pass.** During the June 2020 excursion `northed - 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. - -The cause is that **reanalysis has its own direction-dependent bias**. A spell of unusual wind -- -the June 2020 week was easterly, a sector Hill of Towie rarely sees -- moves every turbine's -residual against reanalysis together, by tens of degrees. The first pass corrected for that, which -wrote the excursion into the northed directions and from there into the farm consensus the second -pass trusts. The outage correlates only because both are weather. - -**Fix: the first pass may act only on a gross recalibration** (`ANCHORING_MIN_STEP_DEG`, 30 -degrees). Its job is to fix the farm in absolute terms, and a step smaller than that is better -left to the second pass, which works against the clean farm consensus and estimates from the raw -direction so nothing is lost by deferring it. The bar sits above reanalysis' own excursions -(~20 degrees) and below a real gross recalibration (Hill of Towie's are 36-177 degrees). - -Blocking the first pass entirely was tried and **rejected**: with only four devices, one -uncorrected 40-degree step drags the median enough to break a real detection. - -A quorum was added alongside -- the consensus needs a strict majority of the farm reporting, not -a floor of three -- because a median over a handful of devices is not the farm's. On its own it -moved the farm total by 3 (143 to 140); it earns its place for the subset case rather than this one. - -### Measured effect of the two fixes - -Hill of Towie, all 21 turbines, **2016-2024**, against v0's published table: - -| | before | after | -|---|---|---| -| changepoints found | 143 | **19** (v0: 28) | -| of which not in v0's table | **127** | **2** | -| v0's changepoints recovered | 16/28 | 17/28 | - -Across a 99-case subset sweep (3 turbine groups x 33 windows, 3 months to 9 years, definitions in -`study/subsets.py` so it re-runs after any change): - -| | before | after | -|---|---|---| -| cases finding changepoints the full run does not | 40/98 | **19/98** | -| total such extras | 287 | **32** | - -The eleven v0 entries not recovered are all small (1.2-8.6 degrees) and mostly one sequence -- -T01's four sub-2.5-degree steps in early 2016 -- which has the signature of veer being chased -rather than a recalibration. - -### What is still imperfect - -`west__year_2021` finds six changepoints the full run does not, and windows starting immediately -after a recalibration (`edge_after_t16_recal`) disagree in both directions. These are recorded -rather than fixed: the analyst inspects the northing result and can supply a hand-corrected -table, so the corrector does not have to be right every time -- it has to be right usually, and -**visible** when it is not, which is what the plots are for. - -### The earlier framing (superseded) - -Kept because the reasoning is instructive, not because it was right: it named the outage as the -cause and the first pass as innocent, and both were wrong. - -Over 2016–2020 nearly every turbine gains changepoint pairs at **2019-11-11/19** and -**2020-06-12/19** — steps of ~±12° and ~±16°, farm-wide, synchronous, self-cancelling within -about eight days. They survive the excursion filter because their size is above -`max_transient_step_deg`. - -Those weeks are farm outages. Two mechanisms, and only the first is fixable at the seam: - -1. **Silent reference substitution.** v0's `add_wf_yawdir` fills a missing farm direction with - reanalysis, which sits degrees away from the farm consensus. In the excursion weeks 35% and - 51% of rows are that fallback, and the turbine count falls to a median of **2** against the - ≥3 rule. Rows where the reference silently changed identity must not be used; excluding them - removes the August 2020 pair. **The v1 path is already correct here** — `_farm_direction` - returns NaN below `min_devices_for_farm_reference` and `yaw_usable` requires a finite - reference, so only the v0 adapter inherits the fallback. - -2. **Changing reference composition.** Excluding the fallback rows does *not* remove the November - 2019 or June 2020 pairs, because the fallback never triggers for them: three turbines still - report, just not the usual three. Turbines have different veer signatures, so a farm median - over a different subset is a different quantity. This is the same root cause as veer, one - level up, and it is **not fixed**: a strict-`xfail` test records it so it announces itself when - it is. - -## Prior art - -**HOGER** (Homogenization Of GEneral Regressions), Engie + CENER, merged into FLASC as -`flasc/data_processing/northing_offset_change_hoger.py` -([PR #240](https://github.com/NatLabRockies/flasc/pull/240)), is the closest published work. - -| | HOGER | R1 | -|---|---|---| -| reference | pairwise turbine-vs-every-other differences (`wrap_180`) | two-pass: reanalysis → farm-median yaw | -| detection | `DecisionTreeRegressor` on time → difference; splits are knots | exact DP on daily circular medians | -| optimality | greedy (CART), `max_depth=4` caps knots at 15 | globally optimal for a given K | -| consensus | keeps a jump only if it appears in >50% of that turbine's pairwise comparisons | robust circular median across the farm | -| tuning | `min_samples_split=1000`, `min_samples_leaf=500`, `ccp_alpha=0.09` | `min_step_deg`, changepoints/year, `min_segment` | -| absolute anchor | none — homogenises only | reanalysis pass | - -Three conclusions: - -- **HOGER is purely differential.** It makes turbines agree with each other but cannot - detect that they all agree on the wrong north. A farm uniformly 180° out is invisible - to it. That is the argument for keeping the reanalysis pass, and it is why the - 180°-wrong-farm unit test below is a first-class acceptance test rather than an edge case. -- **Physical knobs beat pruning constants.** `ccp_alpha=0.09` is not a quantity an analyst - can reason about. `min_step_deg=3` is. -- **The pairwise-consensus trick beats a farm median when N is small.** One jumping - turbine contaminates a 4-turbine median, and the R1 fixture is exactly 4 turbines. Recorded - as a named future option; the reanalysis pass is the anchor in the meantime. - -Background, not code: [Bromm et al., WES 2018](https://wes.copernicus.org/articles/3/395/2018/) -on detecting alignment changes from SCADA; [SkySpecs on north offset](https://skyspecs.com/blog/addressing-north-offset-in-wind-turbines-scada-data/). -OpenOA has no northing module. Nothing found combines globally-optimal circular -segmentation with an absolute anchor. - -## The estimator core — `src/wind_up/northing.py` - -### Public surface - -```python -@dataclass(frozen=True) -class NorthingEffort: - changepoints_per_year: float - min_step_deg: float - refine: bool - grid: pd.Timedelta = pd.Timedelta(days=1) - min_segment: pd.Timedelta = pd.Timedelta(days=7) - -FAST / BALANCED / THOROUGH: NorthingEffort - -def estimate_north_table( - index: pd.DatetimeIndex, - direction_deg: npt.NDArray[np.float64], - *, - reference_deg: npt.NDArray[np.float64], - usable: npt.NDArray[np.bool_], - effort: NorthingEffort | Literal["fast", "balanced", "thorough"] = "balanced", -) -> pd.DataFrame: # columns: timestamp, north_offset - -def apply_north_table( - index: pd.DatetimeIndex, - direction_deg: npt.NDArray[np.float64], - *, - north_table: pd.DataFrame, -) -> npt.NDArray[np.float64] # (direction + offset) % 360 - -def yaw_usable( - *, power: NDArray, downtime_s: NDArray, reference_deg: NDArray, - rated_power: float, timebase_s: int, -) -> npt.NDArray[np.bool_] -``` - -`estimate_north_table` works on any direction field. `yaw_usable` is the turbine mask -(the existing `add_ok_yaw_col` rule: reference present, power above 5% of rated, downtime -below a quarter of the timebase). Masts and LiDARs need a wind-speed-based mask instead; -documented as not yet wired up. - -`apply_north_table` is array-in/array-out so **one table can be applied to several fields -of the same device** — derive the correction from yaw position, apply it to yaw position -*and* a measured wind-direction channel. - -### Algorithm - -1. **Residual.** `d = circ_diff(direction_deg, reference_deg)` where `usable`, NaN elsewhere. -2. **Aggregate** `d` to `effort.grid` bins by per-bin circular median, carrying each bin's - count as a weight. ~730 points for two years. -3. **Prefix sums** of `w·sin(d)`, `w·cos(d)`, `w`. Any segment's weighted resultant-length - cost is then O(1): - `C(i,j) = W(i,j) − hypot(Σ w·sin, Σ w·cos)` — the loss minimised by the circular mean. -4. **Exact DP.** `best[k][j] = min_i best[k−1][i] + C(i,j)`, subject to `min_segment`, - with `K ≤ ceil(changepoints_per_year × years_of_data)`. Choose K by penalised total, - the per-changepoint penalty derived from `min_step_deg` (a step smaller than that is - not worth a changepoint). Vectorised per k; milliseconds at m ≈ 730. -5. **Refine** (when `effort.refine`): re-scan each changepoint at native resolution within - ±1 grid bin, same cost function. -6. **Offsets.** Per segment, `−circ_median(d)` over its native usable rows — robust, and - only K+1 of them. -7. **Iron out excursions.** Drop changepoints that do not persistently move the level and whose - own step is under `max_transient_step_deg` — site veer wandering away and back. -8. **Prune small steps.** Drop any remaining changepoint whose step is under `min_step_deg`, which - is what makes that knob mean what it says. - -Steps 1–5 run **twice**: once on the raw residual to locate the step structure, then again on the -veer-normalised residual (the veer signature measured on the de-stepped record). Offsets always -come from the raw residual, so the correction is absolute. - -Grid stays at one day for every tier: local refinement already recovers sub-day timing, so -a finer grid would quadruple the DP for nothing. `min_segment` prevents pathological -micro-splits. - -### Effort tiers - -There is **one setting**, not a menu — `NorthingSettings`, exposed as `DEFAULT_NORTHING`: - -| field | value | why | -|---|---|---| -| `changepoints_per_year` | 12 | a rate, so a longer record gets a larger budget | -| `min_changepoints` | 3 | a floor, so a short record can still hold several corrections | -| `min_step_deg` | 3° | the smallest step reported | -| `refine` | `True` | measured free (3.6s vs 3.7s on a farm-year) | -| `veer_sector_deg` | 30° | see *Evidence: site veer* | -| `max_transient_step_deg` | 10° | above this a step is never ironed out as wander | -| `grid` / `min_segment` | 1 day / 7 days | search resolution and shortest gap | - -**The effort tiers were built, measured and removed.** The knob was introduced to trade speed for -quality, and the trade turned out not to exist: across a 21-turbine, 2-year farm the whole -spread from the cheapest to the most thorough setting was **3.1 s to 5.2 s**, out of a ~40 s run -dominated by data handling rather than the search. Worse, the cheap tier was *lower quality for -no real saving* — its `1/year` budget missed a genuine changepoint and left a 7.9° worst-case -error against 5.1° for the default. A dial whose cheap end is worse and no faster is not a dial. - -`NorthingSettings` survives as an expert override, not a menu; the tier names and the string API -are gone. Nothing pretends to be a speed control. - -`max_changepoints = ceil(changepoints_per_year × years_of_data)`, so the budget scales with -record length rather than being a fixed count. `fast` is the R1 workhorse: one large -injected step is exactly the K≤1-per-year, no-refinement case. - -### Two-pass driver - -```python -def north_farm( - index: pd.DatetimeIndex, - *, - direction_deg: Mapping[str, npt.NDArray[np.float64]], # device -> direction, on ``index`` - usable: Mapping[str, npt.NDArray[np.bool_]], # device -> mask, on ``index`` - reanalysis_deg: npt.NDArray[np.float64], # on ``index`` - effort: NorthingEffort | str = "balanced", - min_devices_for_farm_reference: int = 3, -) -> dict[str, pd.DataFrame] # device -> absolute north table -``` - -Every device shares one `index`, which is what lets the farm reference be computed by -position. Pass 1 norths each device to `reanalysis_deg`. The northed directions give a farm -direction (circular median across devices, at least `min_devices_for_farm_reference` present -at a timestamp, else NaN). Pass 2 norths each device to that. Returns one absolute north -table per device. - -## Seam 1 — the shared step - -Runs in `CampaignRunner`, which holds the `CampaignSpec` (per the C2 decision that the -runner, not a method, owns this). It writes `columns.northed(role)` — `northed_YawAngleMean` -for `nacelle_position` — **alongside the untouched original**, so existing plots and -diagnostics keep meaning what they say. There is **no `northing_applied` flag**: the -column's presence is the state. - -The step takes a list of direction roles to correct (default `["nacelle_position"]`), -derives one table per turbine from yaw position, and writes `northed_` for each role. - -### `north_offsets`: supplied or not - -`CampaignSpec.north_offsets` becomes `list[...] | None`, defaulting to `None`. Two states: - -| value | meaning | -|---|---| -| `None` (default) | **auto-calculate.** The analyst supplied nothing; wind-up norths from the data. The usual case. | -| a list (possibly empty) | **apply exactly this, discover nothing.** `[]` is simply the case with no corrections to apply, so it needs no separate rule. | - -wind-up does **no checking** of a supplied table in R1 — it applies it and moves on. Checking -a prior and reporting confirmation-or-amendments is real future usage (see *Designed for, not -implemented*), but building it now would mean designing a disagreement threshold and a report -with no caller to validate them against. This mirrors what v0 already does with -`optimize_northing_corrections` versus `northing_corrections_utc`. - -Either way the step writes `northed_`, so downstream consumers find the column -regardless of which branch ran. - -**Consequence for the benchmark:** a campaign that supplies a table never exercises -discovery. The placebo currently loads the real Hill of Towie YAML, so it would -apply-as-supplied and silently stop testing the thing R1 builds. Campaigns meant to exercise -discovery — the R1 fixture above, and C3/C5 — pass `None` explicitly, a per-campaign choice -made visibly rather than a property of the type. - -C3/C5 drop their bespoke northing wiring in favour of this step. - -## Seam 2 — `power_model` sees direction - -`build_reference_features` gains each reference's northed direction, as `sin`/`cos` -companions (LightGBM cannot see that 359° ≈ 1°). Guarded: the method raises, naming the -missing column, if the shared step has not run. `check_reference_only` already blocks the -test turbine's own direction, which is the design-note §3 rule — northing does not make a -post-treatment signal safe. - -**Northed replaces raw, never both.** Direction features come only from northed columns; a -raw direction offered in `extra_cols` when a northed counterpart exists is dropped, with a -log line. - -**It is opt-in, `direction_feature=False` by default — an open decision, not the end state.** -The shared northing step runs in `CampaignRunner`, so the *campaign* path has a northed column -but the *study* path (`build_replicates` → `score_one`, which drives both frozen benchmarks) -does not. Turning the feature on by default would make `power_model` raise on every study -driver. So the flag ships off, campaign method factories turn it on, and two things remain to -decide: - -1. whether the study path should also north (which means the step moving somewhere both paths - share, rather than living only in the runner), and -2. flipping the default and regenerating `study_power_model_compare_baseline.json`. - -Until (1) lands, R1's bites/fixed evidence comes from the campaign path only. That is enough -for the fixture, but it means the frozen benchmark does **not** yet move — contrary to what -this design assumed. - -## Seam 3 — v0 adapter - -`auto_northing_corrections(wf_df, *, cfg, plot_cfg)` keeps its signature and its two-pass -shape. It loops turbines, builds the four core arguments from `RAW_YAWDIR_COL`, -`REANALYSIS_WD_COL` and `WINDFARM_YAWDIR_COL`, and calls the core. v0's own -supplied-versus-discovered switch is unchanged: `cfg.northing_corrections_utc` is applied by -`apply_northing_corrections` as it is today, and `auto_northing_corrections` is what runs -when the analyst asked for discovery. - -Deleted: `CostCircularL1`, `_northing_score`, the move generator, the hill-climb, -`_calc_max_changepoints_to_add`, and the `ruptures` import. `ruptures` then leaves -`pyproject.toml` (dependency and `mypy` override). - -`northing.py`'s `apply_northing_corrections`, `add_wf_yawdir` and `check_wtg_northing` are -unchanged, as are the northing plots. - -This introduces a `wind_up_v0` → `wind_up` import, so the releasable v1 package does not -depend on the legacy one, and W2 promotes the module with no second move. - -**`circular_math` moves too.** The core needs `circ_diff`, `circ_median` and -`rolling_circ_median_approx`, which today live in `src/wind_up_v0/circular_math.py` — v1 -importing them from v0 would be exactly the dependency direction this decision avoids. So -the module moves to `src/wind_up/circular_math.py` and `src/wind_up_v0/circular_math.py` -becomes a re-export, leaving the seven v0 importers and four test modules untouched. - -**Blast radius is small and was verified:** `auto_northing_corrections` is reached only when -`optimize_northing_corrections=True`; both `examples/` set it `False` and use pre-computed -tables, and `hot_context` reads the vendored YAML. No frozen example or benchmark number -moves from this swap. - -**v0 stays verified end-to-end** by re-running the SMARTEOLE and WeDoWind examples. Where a -northing table is supplied the results must be **identical** (that path does not touch the -estimator at all). Where auto-northing runs they need only be **similar** — a different -optimiser finding a slightly different table is the expected outcome, not a regression. Note -that both examples ship with `optimize_northing_corrections=False`, so the auto-northing arm -has to be run with the flag deliberately flipped; it is not exercised by default. - -## The fault and the fixture - -### Fault - -`NorthingStep(turbine, at, offset_deg)` adds `offset_deg` to a turbine's reported -`nacelle_position` from `at`. It changes no power, so `true_uplift` is untouched by -construction. - -This earns a `faults: list = []` field on `SyntheticCampaign` — private ground truth, like -`upgrades` — applied after upgrade injection and to `synthetic_df` only. `CampaignSpec` -never sees it, and the fixture leaves `north_offsets=None` so the step must discover the -step change rather than be told about it: an analyst does not know it happened. The protocol -stays minimal so R2–R4 inherit it: `__call__(synthetic_df, *, columns) -> pd.DataFrame` plus -a `description` for run metadata. - -### Calibrating the fault - -Damage is not monotonic in offset size. Two levers matter more than magnitude: - -- **Timing.** Worst when the step coincides with the changeover in prepost, or falls in the - exact middle of a toggle campaign — that is when the corruption aligns with the contrast - the method is measuring. -- **Where the offset lands.** What matters is how much the power-ratio-versus-direction - shape changes, so a **30° offset can be more damaging than 180°** if it moves a crucial - wake onto a well-populated direction sector. - -So calibration sweeps timing and offset rather than winding magnitude up until something -breaks, and the chosen fault is justified by which sector it moves the wake into. - -### Fault target - -Both v0 and `power_model` key on the **reference** turbine's direction — `main_analysis.py` -sets `ref_wd_col = "ref_YawAngleMean"`, which feeds detrending, the waking scenarios, the -`ref_wd_filter` and the pp binning (`test_wd_col` appears only in a pre/post sanity check); -and `power_model` is barred from the test turbine's own direction by §3. So there is one -fault target — a reference — and one row per mode in the bites table. - -### Fixture - -`benchmarking/campaigns/northing_fixture.py`: **T06** plus its three nearest stable -neighbours, over a 12-month 2017 baseline into 2018. - -- T06 is the measured best fixture turbine (`power_model` mean |err| 0.34%, swing 0.72pp - across the placebo window sweep) and 12mo→2018 is the best-ranked window. -- **T05 is excluded** despite being T06's natural best reference: it carries real northing - steps in 2017–18, so injecting on top of them would muddy attribution. -- Injected uplift is `ws_dependent_cp` (+10% Cp below 5 m/s fading to 0 by 12 m/s) — the - AeroUp shape — so truth is non-zero and we measure error, not placebo drift. -- Declared in **both modes**: prepost changing over 2018-01-01, toggle in 50-minute blocks. - -### Natural-case probe (up front) - -Before any injection: run v0 on T06 prepost 2017→2018 with and without northing correction, -using T05 as reference, to size the naturally occurring instance of this failure mode. This -is a sighting shot that calibrates how large an injected step needs to be to be realistic. - -## Acceptance - -### Per mode, per method (`power_model`, `v0`) — a 2×2, not a pair - -| | northing off | northing on | -|---|---|---| -| **clean** | reference error | must be no worse — *no harm* | -| **faulted** | must be significantly worse — **bites** | must return to ≈ clean — **fixed** | - -The *no harm* cell is the one most easily skipped and the one that would sink C3 if it were -wrong. Where the fault does not bite in toggle (cancellation across on/off blocks), that is -**recorded explicitly** as "no mitigation needed there" — determined empirically, never -assumed. Fault magnitude is calibrated until it bites, per the R-series ground rules. - -Concrete thresholds, so the table has pass/fail rather than adjectives, with `e` the signed -error against the fixture's known truth: - -- **bites**: `|e(faulted, off)| − |e(clean, off)| ≥ 1.0 pp`. T06's `power_model` swing across - the placebo window sweep was 0.72 pp, so a 1 pp degradation is outside its natural - window-to-window scatter and cannot be luck. -- **fixed**: `|e(faulted, on)| − |e(clean, off)| ≤ 0.25 pp`, i.e. the fault's residual damage - is within a third of that natural scatter. -- **no harm**: `|e(clean, on)| − |e(clean, off)| ≤ 0.25 pp`. - -These are the acceptance thresholds; if the natural-case probe or the clean re-baseline -(which changes when `power_model` gains the direction feature) shows T06's scatter is -materially different from 0.72 pp, the thresholds are re-derived from the measured scatter -and the change recorded — they are not loosened to make a run pass. - -### Fixture results (measured) - -T06 + T15/T10/T08, 12 months of 2017 baseline into 2018, `ws_dependent_cp` uplift injected, a -40° `NorthingStep` on T15 at the changeover (prepost) / mid-campaign (toggle). Errors in -percentage points of energy ratio: - -| mode | method | clean/raw | faulted/raw | clean/northed | faulted/northed | bites | fixed | no harm | -|---|---|---|---|---|---|---|---|---| -| prepost | `power_model` | 0.451 | 1.782 | 0.399 | 0.534 | **+1.331 ✓** | **+0.083 ✓** | **−0.052 ✓** | -| prepost | `naive_ratio` | 5.892 | 5.892 | 5.892 | 5.892 | 0.000 | — | — | -| toggle | `power_model` | 0.114 | 0.089 | 0.040 | 0.072 | −0.025 ✗ | +(−0.042) ✓ | −0.075 ✓ | -| toggle | `naive_ratio` | 0.014 | 0.014 | 0.014 | 0.014 | 0.000 | — | — | -| toggle | `toggle_specialist` | 0.014 | 0.014 | 0.014 | 0.014 | 0.000 | — | — | - -**Prepost: the fault bites and the shared step fixes it.** 1.331 pp of damage against the -1.0 pp threshold, closed to 0.083 pp against the 0.25 pp threshold. The threshold was derived -from T06's 0.72 pp placebo swing *before* this was run, and the clean error came out at 0.451 pp -— consistent, so the bar was not set to fit the answer. - -**Toggle: the fault does not bite** (−0.025 pp), so no mitigation is needed there. This is the -cancellation the R-series design anticipated: with on/off blocks interleaved at 50 minutes, a -corruption present in both halves of the contrast largely cancels. **Recorded empirically, as -the ground rules require — not assumed.** - -**`naive_ratio` and `toggle_specialist` are unmoved to the last digit** in all four arms, which -confirms the scoping decision: they read no direction signal, so northing is neither a risk nor -a benefit to them. - -Two further observations: - -- **The direction feature is doing real work.** In the power model's gain ranking the six - `northed_wtc_NacelPos_mean_{sin,cos} @ {T08,T10,T15}` features come in immediately after the - three reference active-power columns and the T10 power minimum — ahead of every ERA5 column. - Without them the fault could not bite at all, which is why Seam 2 is a prerequisite rather - than an enhancement. -- **Northing helps on clean data too** — the *no harm* cell is negative in both modes (0.451 → - 0.399 prepost, 0.114 → 0.040 toggle). The step discovers 7–11 changepoints across the four - turbines even in the clean arm, where the vendored table (an old-optimizer product) says there - are none. Given the farm-scale result, the likely reading is that these are real corrections - the hill-climb missed rather than false positives — but it is inferred from the error moving - the right way, not directly confirmed, and the small-N farm consensus stays a recorded risk. - -### v0 swap — three pieces of evidence - -1. **Ported tests.** `tests/test_optimize_northing.py`'s three `wind_direction_offset` cases - and its injected-changepoint second half pass at the same or tighter tolerances (currently - `abs=1.0` / `abs=1.5` degrees). -2. **The 180°-wrong farm.** A new unit test where every turbine is uniformly 180° out, - proving the reanalysis pass is load-bearing and that pass 2 alone is blind to a - common-mode offset. This is the case HOGER cannot address. -3. **Farm-scale real-data comparison.** Re-derive Hill of Towie's northing with both - implementations and compare turbine-by-turbine, with runtime measured for both. - -### Measured results - -**Homer, July 2023, 2 turbines** (the ported v0 test): the new estimator reproduces the old one -**exactly** — identical median yaw and identical max northing error on all three -`wind_direction_offset` cases. Runtime is a wash at this size (0.6s vs 0.7s): the old -optimizer's cost is in the *search*, which barely runs on one month of two turbines. - -**Hill of Towie, 21 turbines, 2017–2018** (2,207,520 rows, both passes): - -| | old | new | -|---|---|---| -| runtime | 389.9 s | **39.3 s** (9.9x faster) | -| mean max northing error | 2.336° | **2.057°** | -| worst max northing error | 5.121° | **3.665°** | - -Quality is v0's own metric (`check_wtg_northing`: max 20-day rolling circular-median error -against the wind-farm yaw direction), so neither implementation is scored on its own objective. -The new estimator is better or equal on 14 of 21 turbines and never worse by more than 0.25°; -the two largest gains are **T06 5.12 → 2.07** and **T15 4.01 → 2.72**, both turbines where it -finds a real changepoint the hill-climb missed. T06 being the biggest win matters directly — -it is the R1 fixture turbine. - -Agreement is tight: median |new − old| ≤ 0.45° on every turbine. The turbines with a larger p95 -(T06 5.26°, T09 3.46°, T15 3.20°) are precisely those where the new estimator found an extra -changepoint, which is also where its error metric improves most. - -So "same or better performance" holds on both axes, and "MUCH faster" is **9.9x at farm scale** -— a number, and one that grows with record length and turbine count, since the old search cost -scales far worse than the DP's. - -### Test strategy - -The core is pure and array-based, so its tests are fast and synthetic. They land in -`tests/wind_up/test_northing.py` (alongside the existing `tests/wind_up/test_farm.py`); -`tests/test_northing.py` and `tests/test_optimize_northing.py` stay where they are, testing -v0's unchanged helpers and the adapter respectively. - -- known steps at known times, recovered to within tolerance; -- wrap-around at 0/360 in both the raw and the northed signal; -- the all-180° case; -- noise floor: a step just below `min_step_deg` is not reported, one just above is; -- degenerate input (empty, all-NaN, all-unusable, a single segment) returns a valid one-row table; -- effort tiers: `fast` finds one large step; `thorough` finds small ones `fast` misses. - -The fixture runs are drivers, not unit tests. The pytest layer gets a tiny-frame end-to-end -proving the shared step wires through `CampaignRunner`, and that `power_model` raises a -named error when the northed column is absent. - -**At least one test runs on real data** — Hill of Towie, already available via git-lfs. -Synthetic tests pin the algorithm's contract but cannot expose what real SCADA does to it, -so a purely synthetic suite leaves a gap exactly where this issue lives. - -## Designed for, not implemented - -**The incremental re-run.** An analyst supplies a prior north table and asks wind-up to -check it again — either from scratch or with the prior already applied — and wants back -either "confirmed" or a list of amendments. This is normal usage on a live campaign: re-run -monthly as data arrives, and the new data may contain a north jump nobody knew about. - -**Decision 5 (tables are always absolute) is the whole of what R1 does for this**, and it is -enough: a supplied table and a freshly estimated one are directly comparable, so "confirmed -or amended" is a subtraction over two absolute tables. Nothing else needs to exist yet. - -Left for later: a mode that estimates *and* compares rather than choosing between them; a -`prior_mode` selecting whether supplied changepoints are pinned or re-optimised; and the -report that states "confirmed" or lists amendments. - -Deliberately **not** built now: `estimate_north_table` takes no `prior` argument. Seeding the -search from a supplied table has no caller under the supplied-or-discovered rule above, and a -parameter that sits unused across issues drifts — the same argument the C2 design made for -keeping unread fields off `CampaignContext`. It is a small addition when a caller exists. - -The success condition for all of this is that it stays rarely used — a norther fast and -accurate enough that supplying a table stops being worth the analyst's time. - -**Pairwise consensus.** HOGER's ">50% of pairwise comparisons" vote as an alternative to -the farm-median reference, for farms with few turbines. - -**Masts and LiDARs.** The core already accepts any direction field; what is missing is a -wind-speed-based `usable` helper and the plumbing to declare non-turbine devices. - -## Risks - -- **Making the fault bite `power_model` at all.** The direction feature is new, so how much - the model leans on it is unknown until measured. If it leans lightly, the injected step may - need to be large to bite, which strains realism. Mitigated by the natural-case probe, which - sizes a real occurrence first. If the feature turns out to carry little weight, ERA5 wind - direction can be withheld from `power_model` as a deliberate intervention, forcing it onto - the turbine direction signal and putting the northing step under real pressure. -- **Small-N farm reference.** Four turbines with one jumping makes the farm-median direction - noisier than at HoT's 21. The reanalysis pass anchors it, and pairwise consensus is the - recorded fallback. -- **`min_step_deg` → penalty conversion.** The mapping from a degrees threshold to a - resultant-length penalty needs calibrating against the noise floor rather than derived - once on paper; the noise-floor test is what pins it. -- **v0 parity on real data.** "Same or better" is judged on the HoT farm-scale comparison. - A turbine where the new table differs materially needs explaining, not averaging away. diff --git a/docs/v1/issues_campaigns.md b/docs/v1/issues_campaigns.md index 1d61cff7..fb38d8b6 100644 --- a/docs/v1/issues_campaigns.md +++ b/docs/v1/issues_campaigns.md @@ -570,6 +570,12 @@ up. from before env-vars / `Path.home()` were used — and rework `wind_up_v0/constants.py` path handling accordingly (env vars / `Path.home()` instead of `PROJECTROOT_DIR`- relative, so nothing depends on those root folders). +- **Clean up the development-phase documentation.** `docs/superpowers/` (design notes + and plans) and `CLAUDE.md` are untracked and git-ignored as of 2026-09-03, so tracked + files that cite them — `docs/v1/issues_campaigns.md`, `docs/v1/findings_campaigns.md` + — now point at paths a fresh clone will not have. Decide per document what a released + v1 should carry: fold what is still true into `docs/methodology.md` or `docs/v1/`, and + drop the citations that are only development history. - **A campaign is declared, not scripted.** `CampaignSpec` gains a simple user-facing declaration — a YAML file it initializes from — so an analyst describes From 240bcf315d483271ef73b62c3f480af8e640dea1 Mon Sep 17 00:00:00 2001 From: aclerc Date: Thu, 3 Sep 2026 16:08:03 +0100 Subject: [PATCH 09/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- .../baselines/example_prepost_study.py | 7 ++ .../baselines/example_toggle_study.py | 7 ++ .../baselines/inspect_prepost_hard_case.py | 28 ++++-- .../baselines/inspect_wake_steering_case.py | 13 +++ benchmarking/baselines/power_model/method.py | 2 +- .../baselines/study_power_model_compare.py | 7 ++ .../baselines/study_toggle_methods_compare.py | 7 ++ benchmarking/campaigns/methods.py | 5 -- benchmarking/campaigns/northing_fixture.py | 13 +-- benchmarking/campaigns/runner.py | 7 +- .../{campaigns => harness}/northing.py | 44 +++++---- benchmarking/harness/replicates.py | 40 ++++++++- benchmarking/harness/scoring.py | 5 +- docs/v1/issues_campaigns.md | 8 ++ .../baselines/test_power_model_method.py | 16 ++++ .../{campaigns => harness}/test_northing.py | 89 ++++++++++++++----- 16 files changed, 228 insertions(+), 70 deletions(-) rename benchmarking/{campaigns => harness}/northing.py (81%) rename tests/benchmarking/{campaigns => harness}/test_northing.py (59%) diff --git a/benchmarking/baselines/example_prepost_study.py b/benchmarking/baselines/example_prepost_study.py index c6779538..fa515f6c 100644 --- a/benchmarking/baselines/example_prepost_study.py +++ b/benchmarking/baselines/example_prepost_study.py @@ -41,6 +41,7 @@ from benchmarking.baselines.v0_binned import V0BinnedMethod from benchmarking.harness import Method, StudyConfig, leaderboard, plot_campaign_curves, score_study from benchmarking.harness.example_hot_study import OracleMethod +from benchmarking.harness.northing import era5_direction from benchmarking.synthetic import HOT_COLUMNS, HOT_RATED_POWER_KW from benchmarking.synthetic.make_example_datasets import example_profiles from benchmarking.synthetic.sources.hill_of_towie import load_hot_scada @@ -116,6 +117,11 @@ def run_prepost_study( out_dir.mkdir(parents=True, exist_ok=True) context = build_hot_v0_context(data_dir=data_dir, wtg_names=DEFAULT_TURBINE_SUBSET) + # The shared northing step runs per replicate, so every method sees a north-calibrated + # direction that wind-up discovered for itself rather than one supplied to it. + era5_wd = era5_direction( + context.reanalysis_datasets[0].data, pd.DatetimeIndex(base_scada.index.unique()).sort_values() + ) scratch_dir = out_dir / "windup_runs" all_results = [] @@ -159,6 +165,7 @@ def run_prepost_study( methods=methods, study=study, profile_name=profile_name, + era5_wd=era5_wd, on_method_complete=partial(save_per_method_curve, out_dir, profile_name), ) summary = leaderboard(results) diff --git a/benchmarking/baselines/example_toggle_study.py b/benchmarking/baselines/example_toggle_study.py index a0d72010..cacf88f0 100644 --- a/benchmarking/baselines/example_toggle_study.py +++ b/benchmarking/baselines/example_toggle_study.py @@ -42,6 +42,7 @@ from benchmarking.baselines.v0_binned import V0BinnedMethod from benchmarking.harness import Method, StudyConfig, leaderboard, plot_campaign_curves, score_study from benchmarking.harness.example_hot_study import OracleMethod +from benchmarking.harness.northing import era5_direction from benchmarking.synthetic import HOT_COLUMNS, HOT_RATED_POWER_KW, ConstantCpChange from benchmarking.synthetic.sources.hill_of_towie import load_hot_scada @@ -89,6 +90,11 @@ def run_toggle_study( out_dir.mkdir(parents=True, exist_ok=True) context = build_hot_v0_context(data_dir=data_dir, wtg_names=DEFAULT_TURBINE_SUBSET) + # The shared northing step runs per replicate, so every method sees a north-calibrated + # direction that wind-up discovered for itself rather than one supplied to it. + era5_wd = era5_direction( + context.reanalysis_datasets[0].data, pd.DatetimeIndex(base_scada.index.unique()).sort_values() + ) scratch_dir = out_dir / "windup_runs" all_results = [] @@ -127,6 +133,7 @@ def run_toggle_study( methods=methods, study=study, profile_name=profile_name, + era5_wd=era5_wd, on_method_complete=partial(save_per_method_curve, out_dir, profile_name), ) summary = leaderboard(results) diff --git a/benchmarking/baselines/inspect_prepost_hard_case.py b/benchmarking/baselines/inspect_prepost_hard_case.py index dbbe4b7e..3f06ef57 100644 --- a/benchmarking/baselines/inspect_prepost_hard_case.py +++ b/benchmarking/baselines/inspect_prepost_hard_case.py @@ -40,7 +40,7 @@ MIN_PRE_MONTHS, default_output_root, ) -from benchmarking.baselines.hot_context import build_hot_v0_context +from benchmarking.baselines.hot_context import HotV0Context, build_hot_v0_context from benchmarking.baselines.naive_ratio import NaiveRatioMethod from benchmarking.baselines.overnight_common import start_overnight_run from benchmarking.baselines.overnight_profiles import overnight_profiles @@ -61,6 +61,7 @@ treated_activity_mask, window_row_mask, ) +from benchmarking.harness.northing import era5_direction from benchmarking.synthetic import HOT_COLUMNS, HOT_RATED_POWER_KW from benchmarking.synthetic.sources.hill_of_towie import load_hot_scada @@ -110,11 +111,17 @@ def _select_replicate(replicates: list[Replicate], test_wtg: str) -> Replicate: def _pin_case( - scada_df: pd.DataFrame, *, study: StudyConfig, profile_name: str, test_wtg: str, campaign_months: int + scada_df: pd.DataFrame, + *, + study: StudyConfig, + profile_name: str, + test_wtg: str, + campaign_months: int, + era5_wd: pd.Series, ) -> tuple[Replicate, MethodInput, float, CampaignWindow]: """Build the pinned replicate, its shared ``MethodInput``, the ground-truth uplift, and the window.""" profile = overnight_profiles()[profile_name] - replicates = build_replicates(scada_df, profile=profile, study=study) + replicates = build_replicates(scada_df, profile=profile, study=study, era5_wd=era5_wd) rep = _select_replicate(replicates, test_wtg) windows = campaign_windows( @@ -169,13 +176,12 @@ def _power_model(out_dir: Path, era5_hourly_df: pd.DataFrame, *, save_plots: boo ) -def _build_methods(out_dir: Path, *, include_v0: bool) -> list[Method]: +def _build_methods(out_dir: Path, *, context: HotV0Context, include_v0: bool) -> list[Method]: """Return the methods to inspect, each writing diagnostics (plots on) into its own subfolder. One ``power_model`` run folder: with conditional uplift on (the default) it carries the overall diagnostics plus the conditional CSVs (``conditional/``) and the step-7 implied-shrinkage plot. """ - context = build_hot_v0_context(wtg_names=DEFAULT_TURBINE_SUBSET) era5 = context.reanalysis_datasets[0].data methods: list[Method] = [ NaiveRatioMethod( @@ -293,10 +299,18 @@ def inspect_prepost_hard_case( wtg_names=DEFAULT_TURBINE_SUBSET, ) + context = build_hot_v0_context(wtg_names=DEFAULT_TURBINE_SUBSET) rep, mi, truth, window = _pin_case( - scada_df, study=study, profile_name=profile_name, test_wtg=test_wtg, campaign_months=campaign_months + scada_df, + study=study, + profile_name=profile_name, + test_wtg=test_wtg, + campaign_months=campaign_months, + era5_wd=era5_direction( + context.reanalysis_datasets[0].data, pd.DatetimeIndex(scada_df.index.unique()).sort_values() + ), ) - methods = _build_methods(out_dir, include_v0=include_v0) + methods = _build_methods(out_dir, context=context, include_v0=include_v0) summary, outputs = _run_methods(methods, mi=mi, truth=truth) _plot_conditional_uplift( diff --git a/benchmarking/baselines/inspect_wake_steering_case.py b/benchmarking/baselines/inspect_wake_steering_case.py index 5298e655..a92f8942 100644 --- a/benchmarking/baselines/inspect_wake_steering_case.py +++ b/benchmarking/baselines/inspect_wake_steering_case.py @@ -69,6 +69,7 @@ condition_bins, plot_conditional_uplift, ) +from benchmarking.harness.northing import era5_direction, north_scada from benchmarking.synthetic import ( HOT_COLUMNS, HOT_RATED_POWER_KW, @@ -438,6 +439,18 @@ def inspect_wake_steering_case( era5 = context.reanalysis_datasets[0].data full_dataset, steering = _build_dataset(scada_df, metadata_df) + # The shared northing step, discovering as it does on every other path -- the methods read + # a north-calibrated direction wind-up worked out, not the table the injection was gated on. + full_dataset = replace( + full_dataset, + synthetic_df=north_scada( + full_dataset.synthetic_df, + columns=HOT_COLUMNS, + north_offsets=None, + rated_power_kw=HOT_RATED_POWER_KW, + era5_wd=era5_direction(era5, pd.DatetimeIndex(full_dataset.synthetic_df.index.unique()).sort_values()), + ), + ) schedule = ToggleSchedule(period=TOGGLE_PERIOD, start=TOGGLE_START) nadir, half_width = _pair_sector(steering, margin_deg=wd_margin_deg) diff --git a/benchmarking/baselines/power_model/method.py b/benchmarking/baselines/power_model/method.py index b0846d3a..cfb28441 100644 --- a/benchmarking/baselines/power_model/method.py +++ b/benchmarking/baselines/power_model/method.py @@ -318,7 +318,7 @@ class PowerModelMethod: reference_stat_cols: tuple[str, ...] = () era5_exclude: tuple[str, ...] = CURATED_ERA5_EXCLUDE availability_feature: bool = False - direction_feature: bool = False + direction_feature: bool = True adaptive_time_decay: bool = True time_decay_half_life_days: float | None = None diff --git a/benchmarking/baselines/study_power_model_compare.py b/benchmarking/baselines/study_power_model_compare.py index 830437cd..52634bb3 100644 --- a/benchmarking/baselines/study_power_model_compare.py +++ b/benchmarking/baselines/study_power_model_compare.py @@ -106,6 +106,7 @@ plot_conditional_uplift, score_study, ) +from benchmarking.harness.northing import era5_direction from benchmarking.synthetic import HOT_COLUMNS, HOT_RATED_POWER_KW from benchmarking.synthetic.sources.hill_of_towie import load_hot_scada @@ -245,6 +246,11 @@ def run_power_model( wtg_names=DEFAULT_TURBINE_SUBSET, ) context = build_hot_v0_context(wtg_names=DEFAULT_TURBINE_SUBSET) + # The shared northing step runs per replicate, so every method sees a north-calibrated + # direction that wind-up discovered for itself rather than one supplied to it. + era5_wd = era5_direction( + context.reanalysis_datasets[0].data, pd.DatetimeIndex(scada_df.index.unique()).sort_values() + ) study = _prepost_study() if mode == "prepost" else _toggle_study() all_results = [] @@ -267,6 +273,7 @@ def run_power_model( methods=[naive, method], study=study, profile_name=profile_name, + era5_wd=era5_wd, on_method_complete=partial(save_per_method_curve, out_dir, profile_name), ) results.to_csv(out_dir / f"results_{profile_name}.csv", index=False) diff --git a/benchmarking/baselines/study_toggle_methods_compare.py b/benchmarking/baselines/study_toggle_methods_compare.py index c372eba9..6d3afd54 100644 --- a/benchmarking/baselines/study_toggle_methods_compare.py +++ b/benchmarking/baselines/study_toggle_methods_compare.py @@ -72,6 +72,7 @@ plot_campaign_curves, score_study, ) +from benchmarking.harness.northing import era5_direction from benchmarking.synthetic import HOT_COLUMNS, HOT_RATED_POWER_KW, ConstantCpChange from benchmarking.synthetic.sources.hill_of_towie import load_hot_scada @@ -227,6 +228,11 @@ def run_study(out_dir: Path, *, profiles: list[str] | None = None) -> pd.DataFra wtg_names=DEFAULT_TURBINE_SUBSET, ) context = build_hot_v0_context(wtg_names=DEFAULT_TURBINE_SUBSET) + # The shared northing step runs per replicate, so every method sees a north-calibrated + # direction that wind-up discovered for itself rather than one supplied to it. + era5_wd = era5_direction( + context.reanalysis_datasets[0].data, pd.DatetimeIndex(scada_df.index.unique()).sort_values() + ) study = toggle_study() all_results = [] @@ -241,6 +247,7 @@ def run_study(out_dir: Path, *, profiles: list[str] | None = None) -> pd.DataFra methods=methods, study=study, profile_name=profile_name, + era5_wd=era5_wd, ) results.to_csv(out_dir / f"results_{profile_name}.csv", index=False) all_results.append(results) diff --git a/benchmarking/campaigns/methods.py b/benchmarking/campaigns/methods.py index 8c332595..6f36679a 100644 --- a/benchmarking/campaigns/methods.py +++ b/benchmarking/campaigns/methods.py @@ -24,7 +24,6 @@ def carried_forward_methods( out_dir: Path, era5_hourly_df: pd.DataFrame | None = None, include_power_model: bool = True, - direction_feature: bool = False, ) -> list[Method]: """Build the methods applicable to ``spec``, each writing into its own subfolder of ``out_dir``. @@ -36,9 +35,6 @@ def carried_forward_methods( :param out_dir: the turbine's output folder; each method gets a subfolder named after it :param era5_hourly_df: reanalysis for the power model; omit to run it without ERA5 features :param include_power_model: build the power model (needs the ``ml`` dependency group) - :param direction_feature: give the power model each reference's north-calibrated direction. - Requires the shared northing step to have run over the frame, so it is off unless the - caller knows the runner northed. """ methods: list[Method] = [NaiveRatioMethod(columns=HOT_COLUMNS, out_dir=out_dir / "naive_ratio", save_plots=True)] if spec.mode == "toggle": @@ -59,7 +55,6 @@ def carried_forward_methods( era5_hourly_df=era5_hourly_df, conditions=PowerModelMethod.conditions if era5_hourly_df is not None else (), availability_feature=False, - direction_feature=direction_feature, era5_exclude=CURATED_ERA5_EXCLUDE, model_params=dict(TUNED_MODEL_PARAMS), out_dir=out_dir / "power_model", diff --git a/benchmarking/campaigns/northing_fixture.py b/benchmarking/campaigns/northing_fixture.py index f1d38786..f8bbbc82 100644 --- a/benchmarking/campaigns/northing_fixture.py +++ b/benchmarking/campaigns/northing_fixture.py @@ -37,6 +37,7 @@ from benchmarking.campaigns.declaration import SyntheticCampaign from benchmarking.campaigns.methods import carried_forward_methods from benchmarking.campaigns.runner import CampaignRunner +from benchmarking.harness.northing import era5_direction from benchmarking.synthetic import HOT_RATED_POWER_KW, NorthingStep, ToggleSchedule, WindSpeedCpChange from benchmarking.synthetic.sources.hill_of_towie import load_hot_metadata, load_hot_scada @@ -67,8 +68,6 @@ FAULT_TURBINE = "T15" FAULT_OFFSET_DEG = 40.0 -ERA5_WD_COL = "wind_direction_100m" - def analysis_period(mode: Literal["prepost", "toggle"]) -> tuple[pd.Timestamp, pd.Timestamp]: """Return the whole record the methods see for ``mode``: the baseline plus the campaign.""" @@ -138,12 +137,6 @@ def fixture_campaign( ) -def _era5_direction(era5_df: pd.DataFrame, index: pd.DatetimeIndex) -> pd.Series: - """Return the hourly ERA5 wind direction carried onto ``index``, held within each hour.""" - hourly = era5_df[ERA5_WD_COL] - return hourly.reindex(hourly.index.union(index)).ffill(limit=6).reindex(index) - - def run_cell( *, mode: Literal["prepost", "toggle"], @@ -167,10 +160,8 @@ def run_cell( out_dir=out_dir / wtg, era5_hourly_df=era5_df if include_power_model else None, include_power_model=include_power_model, - # the runner norths below, so the northed column the feature needs is always present - direction_feature=True, ), - era5_wd=_era5_direction(era5_df, index), + era5_wd=era5_direction(era5_df, index), ) return runner.run() diff --git a/benchmarking/campaigns/runner.py b/benchmarking/campaigns/runner.py index 1ff3182f..ce3780a2 100644 --- a/benchmarking/campaigns/runner.py +++ b/benchmarking/campaigns/runner.py @@ -9,8 +9,8 @@ import pandas as pd from benchmarking.campaigns.context import context_for -from benchmarking.campaigns.northing import DEFAULT_NORTHING_ROLES, north_campaign_scada from benchmarking.harness import CampaignWindow, Replicate, score_one, truth_mask +from benchmarking.harness.northing import DEFAULT_NORTHING_ROLES, north_scada from wind_up import TurbineUplift, farm_uplift from wind_up.northing import DEFAULT_NORTHING @@ -198,10 +198,11 @@ def _visible_dataset(self) -> SyntheticDataset: keep = self._visible_mask(synthetic) visible = synthetic[keep] if self._should_north(): - visible = north_campaign_scada( + visible = north_scada( visible, - spec=self._spec, columns=self._dataset.columns, + north_offsets=self._spec.north_offsets, + rated_power_kw=self._spec.rated_power_kw, era5_wd=self._era5_wd, roles=self._northing_roles, settings=self._northing_settings, diff --git a/benchmarking/campaigns/northing.py b/benchmarking/harness/northing.py similarity index 81% rename from benchmarking/campaigns/northing.py rename to benchmarking/harness/northing.py index 8a002190..c2b87b79 100644 --- a/benchmarking/campaigns/northing.py +++ b/benchmarking/harness/northing.py @@ -1,14 +1,14 @@ """The shared northing step: north-calibrate every turbine's direction, upstream of every method. -Runs in the campaign runner, which holds the :class:`~benchmarking.campaigns.declaration.CampaignSpec`, -so every method inherits the correction rather than each hand-rolling one. The step writes +Runs on both paths that feed methods -- the campaign runner and the study replicates -- so every +method inherits the correction rather than each hand-rolling one. The step writes ``columns.northed(role)`` alongside the untouched original, so plots and diagnostics of the raw signal keep meaning what they say; whether it has run is written in the frame as the presence of that column, with no separate flag to disagree with it. -``spec.north_offsets`` decides which of two things happens: +``north_offsets`` decides which of two things happens: -* ``None`` (the default) -- discover the corrections from the data; +* ``None`` -- discover the corrections from the data; * a list (possibly empty) -- apply exactly those, discovering nothing. """ @@ -25,7 +25,6 @@ if TYPE_CHECKING: from collections.abc import Sequence - from benchmarking.campaigns.declaration import CampaignSpec from benchmarking.synthetic import ColumnSchema logger = logging.getLogger(__name__) @@ -34,6 +33,15 @@ # position and may be applied to further direction channels of the same turbine. DEFAULT_NORTHING_ROLES: tuple[str, ...] = ("nacelle_position",) +# Open-Meteo's hub-height wind direction, the reanalysis anchor discovery is measured against. +ERA5_WD_COL = "wind_direction_100m" + + +def era5_direction(era5_df: pd.DataFrame, index: pd.DatetimeIndex) -> pd.Series: + """Return the hourly ERA5 wind direction carried onto ``index``, held within each hour.""" + hourly = era5_df[ERA5_WD_COL] + return hourly.reindex(hourly.index.union(index)).ffill(limit=6).reindex(index) + def _north_table_from_offsets( offsets: Sequence[tuple[str, pd.Timestamp, float]], *, turbine: str, start: pd.Timestamp @@ -87,11 +95,12 @@ def _directions( return out -def north_campaign_scada( +def north_scada( scada_df: pd.DataFrame, *, - spec: CampaignSpec, columns: ColumnSchema, + north_offsets: Sequence[tuple[str, pd.Timestamp, float]] | None, + rated_power_kw: float, era5_wd: pd.Series | None = None, roles: Sequence[str] = DEFAULT_NORTHING_ROLES, settings: NorthingSettings = DEFAULT_NORTHING, @@ -102,10 +111,11 @@ def north_campaign_scada( requested role, so a turbine's channels stay mutually consistent. The originals are untouched. :param scada_df: long-format SCADA, timestamps indexed, turbines in ``columns.turbine`` - :param spec: the campaign, read for ``north_offsets``, ``rated_power_kw`` and the turbine column - :param columns: the source-native schema naming the direction role(s) + :param columns: the source-native schema naming the turbine and direction role(s) + :param north_offsets: ``None`` to discover the corrections, or the exact table to apply + :param rated_power_kw: turbine rating, for deciding which rows are usable for northing :param era5_wd: reanalysis wind direction (deg) covering the frame, the absolute anchor for - discovery. Required when ``spec.north_offsets`` is ``None``. + discovery. Required when ``north_offsets`` is ``None``. :param roles: the direction roles to write a ``northed_`` companion for :param settings: how the changepoint search is bounded, when discovering :return: a copy of ``scada_df`` with ``columns.northed(role)`` added for each role @@ -124,17 +134,15 @@ def north_campaign_scada( return scada_df roles = present - if spec.north_offsets is not None: - tables = { - wtg: _north_table_from_offsets(spec.north_offsets, turbine=wtg, start=index.min()) for wtg in turbines - } - logger.info("applying %d declared northing correction(s); discovering none", len(spec.north_offsets)) + if north_offsets is not None: + tables = {wtg: _north_table_from_offsets(north_offsets, turbine=wtg, start=index.min()) for wtg in turbines} + logger.info("applying %d declared northing correction(s); discovering none", len(north_offsets)) else: if era5_wd is None: msg = ( - "north_campaign_scada needs era5_wd to discover northing corrections: reanalysis is the " + "north_scada needs era5_wd to discover northing corrections: reanalysis is the " "absolute anchor, without which a farm that is uniformly wrong looks self-consistent. " - "Supply era5_wd, or declare spec.north_offsets to apply a known table instead." + "Supply era5_wd, or declare north_offsets to apply a known table instead." ) raise ValueError(msg) reference = era5_wd.reindex(index).to_numpy(dtype=float) @@ -155,7 +163,7 @@ def north_campaign_scada( turbines=turbines, index=index, reference_deg=reference, - rated_power_kw=spec.rated_power_kw, + rated_power_kw=rated_power_kw, timebase_s=timebase_s, ), reanalysis_deg=reference, diff --git a/benchmarking/harness/replicates.py b/benchmarking/harness/replicates.py index 6a808080..c12076e9 100644 --- a/benchmarking/harness/replicates.py +++ b/benchmarking/harness/replicates.py @@ -12,13 +12,14 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, Literal import numpy as np from benchmarking.harness.campaign import resolve_campaign_grid -from benchmarking.synthetic import HOT_COLUMNS, ToggleSchedule, generate_dataset +from benchmarking.harness.northing import north_scada +from benchmarking.synthetic import HOT_COLUMNS, HOT_RATED_POWER_KW, ToggleSchedule, generate_dataset if TYPE_CHECKING: from collections.abc import Iterator @@ -125,6 +126,8 @@ def iter_replicates( profile: list, study: StudyConfig, columns: ColumnSchema = HOT_COLUMNS, + era5_wd: pd.Series | None = None, + rated_power_kw: float = HOT_RATED_POWER_KW, ) -> Iterator[Replicate]: """Yield ``study.n_replicates`` replicates of ``profile`` one at a time. @@ -134,6 +137,12 @@ def iter_replicates( each be freed rather than materialising them all. :param columns: the source-native column schema ``base_scada`` is keyed by + :param era5_wd: reanalysis wind direction covering ``base_scada``. Supplying it runs the shared + northing step on each replicate, so methods reading ``columns.northed(role)`` find it; + without it no replicate is northed. Each replicate norths its own generated frame rather + than sharing a table discovered once, so the step has to find the corrections unaided and a + direction-moving upgrade in ``profile`` stays consistent with its northed companion. + :param rated_power_kw: turbine rating, passed to the generator and to the northing step """ subset = base_scada[base_scada[columns.turbine].isin(study.turbine_subset)] candidates = _candidate_starts(subset.index, study.treatment_start_range) @@ -151,8 +160,20 @@ def iter_replicates( mode=study.mode, upgrade_timing=upgrade_timing, columns=columns, + rated_power_kw=rated_power_kw, seed=study.seed, ) + if era5_wd is not None: + dataset = replace( + dataset, + synthetic_df=north_scada( + dataset.synthetic_df, + columns=columns, + north_offsets=None, + rated_power_kw=rated_power_kw, + era5_wd=era5_wd, + ), + ) yield Replicate( dataset=dataset, test_wtg=test_wtg, @@ -168,6 +189,8 @@ def build_replicates( profile: list, study: StudyConfig, columns: ColumnSchema = HOT_COLUMNS, + era5_wd: pd.Series | None = None, + rated_power_kw: float = HOT_RATED_POWER_KW, ) -> list[Replicate]: """Draw ``study.n_replicates`` replicates of ``profile`` from ``base_scada``. @@ -178,8 +201,19 @@ def build_replicates( the ensemble is large enough for that to matter. :param columns: the source-native column schema ``base_scada`` is keyed by + :param era5_wd: reanalysis wind direction; see :func:`iter_replicates` + :param rated_power_kw: turbine rating, passed to the generator and to the northing step """ - return list(iter_replicates(base_scada, profile=profile, study=study, columns=columns)) + return list( + iter_replicates( + base_scada, + profile=profile, + study=study, + columns=columns, + era5_wd=era5_wd, + rated_power_kw=rated_power_kw, + ) + ) def _candidate_starts( diff --git a/benchmarking/harness/scoring.py b/benchmarking/harness/scoring.py index c0fe290e..107df453 100644 --- a/benchmarking/harness/scoring.py +++ b/benchmarking/harness/scoring.py @@ -80,6 +80,7 @@ def score_study( study: StudyConfig, profile_name: str = "profile", columns: ColumnSchema = HOT_COLUMNS, + era5_wd: pd.Series | None = None, on_method_complete: Callable[[str, pd.DataFrame], None] | None = None, ) -> pd.DataFrame: """Score ``methods`` on ``study`` over ``profile`` injected into ``base_scada``. @@ -90,13 +91,15 @@ def score_study( ``baseline_start`` and ``activity_end`` — so a result is self-describing. :param columns: the source-native column schema ``base_scada`` is keyed by + :param era5_wd: reanalysis wind direction; supplying it runs the shared northing step on each + replicate, so methods reading a northed direction find one. See :func:`iter_replicates`. :param on_method_complete: optional hook called as each method finishes its full instance sweep, with ``(method_name, that_method's_rows)`` (the same rows it contributes to the returned frame). Lets a caller act on a method's results early — e.g. plot them — instead of waiting for every method, useful when a slow method runs last. It never changes the returned frame; order methods fastest-first to get the earliest feedback. """ - replicates = build_replicates(base_scada, profile=profile, study=study, columns=columns) + replicates = build_replicates(base_scada, profile=profile, study=study, columns=columns, era5_wd=era5_wd) data_start = base_scada.index.min() data_end = base_scada.index.max() instances = _materialise_instances(replicates, study, data_start=data_start, data_end=data_end) diff --git a/docs/v1/issues_campaigns.md b/docs/v1/issues_campaigns.md index fb38d8b6..7e71cea2 100644 --- a/docs/v1/issues_campaigns.md +++ b/docs/v1/issues_campaigns.md @@ -435,6 +435,9 @@ the developed solution can be a drop-in replacement for the existing src/wind_up shared northing step has to reach the study path too (it currently runs only in `CampaignRunner`, so the study drivers behind the frozen benchmarks have no northed column), and `study_power_model_compare_baseline.json` is regenerated. +The study path norths **per replicate**, discovering for itself rather than being handed a prior +table — the benchmark has to measure wind-up running unaided. The step therefore lives in +`benchmarking/harness/northing.py`, which both paths can reach, rather than under `campaigns/`. the northing tool **shows its working**: per-turbine plots of the time-averaged residual against the reference with the fitted step function overlaid, before and after correction, so a user can see what was changed and judge it. Time averaging is what smears out veer. @@ -570,6 +573,11 @@ up. from before env-vars / `Path.home()` were used — and rework `wind_up_v0/constants.py` path handling accordingly (env vars / `Path.home()` instead of `PROJECTROOT_DIR`- relative, so nothing depends on those root folders). +- **Shore up `power_model` unit coverage.** R1 flipped `direction_feature` on by + default; the existing suite was recovered by fixture edits rather than deletions, but + it is thin in places the benchmarks cannot reach — error paths, the `CampaignContext` + seam, and the reference-only (design note §3) guard. A released package should not + rest on the benchmarks alone for those. - **Clean up the development-phase documentation.** `docs/superpowers/` (design notes and plans) and `CLAUDE.md` are untracked and git-ignored as of 2026-09-03, so tracked files that cite them — `docs/v1/issues_campaigns.md`, `docs/v1/findings_campaigns.md` diff --git a/tests/benchmarking/baselines/test_power_model_method.py b/tests/benchmarking/baselines/test_power_model_method.py index 822b9752..b66f3781 100644 --- a/tests/benchmarking/baselines/test_power_model_method.py +++ b/tests/benchmarking/baselines/test_power_model_method.py @@ -39,6 +39,8 @@ _POWER_MAX = "wtc_ActPower_max" _POWER_MIN = "wtc_ActPower_min" _POWER_SD = "wtc_ActPower_stddev" +_YAW = "wtc_NacelPos_mean" +_NORTHED_YAW = f"northed_{_YAW}" _COLUMNS = ColumnSchema( turbine=_TURBINE, active_power=_POWER, @@ -47,8 +49,12 @@ wind_speed_sd=_WS_SD, gen_rpm="wtc_GenRpm_mean", availability=_AVAIL, + nacelle_position=_YAW, ) +# Per-turbine north miscalibration the northed column removes. +_YAW_OFFSETS = {"T1": 0.0, "R1": 7.0, "R2": -5.0, "R3": 3.0} + # Small/fast LightGBM so the toy data (a few thousand rows) is fit well. _FAST_PARAMS = {"n_estimators": 120, "learning_rate": 0.1, "num_leaves": 31, "min_child_samples": 20} @@ -66,6 +72,9 @@ def _toy_scada(n: int, *, uplift: float, treated: np.ndarray, seed: int = 0) -> r3 = rng.normal(800, 150, n) base_test = 0.4 * r1 + 0.35 * r2 + 0.25 * r3 + rng.normal(0, 15, n) test_power = np.where(treated, base_test * (1.0 + uplift), base_test) + # One site-wide direction every turbine sees, so the direction features are plausible rather + # than noise; each turbine reports it through its own north miscalibration. + wind_direction = 180.0 + 60.0 * np.sin(2.0 * np.pi * np.arange(n) / 1000.0) frames = { "T1": test_power, "R1": r1, @@ -84,6 +93,8 @@ def _toy_scada(n: int, *, uplift: float, treated: np.ndarray, seed: int = 0) -> _POWER_MAX: power * 1.15, _POWER_MIN: power * 0.85, _POWER_SD: np.abs(power) / 20.0, + _YAW: (wind_direction + _YAW_OFFSETS[name]) % 360.0, + _NORTHED_YAW: wind_direction % 360.0, }, index=idx, ) @@ -697,6 +708,7 @@ def _shrinkage_scada(n: int, *, uplift: float, treated: np.ndarray, seed: int = w = rng.uniform(3.0, 12.0, n) # latent wind speed, i.i.d. -> matched across periods curve = 20.0 * w**2 # steep power curve (≈180..2880 kW), so per-ws-bin compression is visible test_power = np.where(treated, curve * (1.0 + uplift), curve) + rng.normal(0.0, 20.0, n) + wind_direction = 180.0 + 60.0 * np.sin(2.0 * np.pi * np.arange(n) / 1000.0) parts = [ pd.DataFrame( { @@ -706,6 +718,8 @@ def _shrinkage_scada(n: int, *, uplift: float, treated: np.ndarray, seed: int = _AVAIL: 600.0, _WS: w, _WS_SD: 0.05 * w, + _YAW: (wind_direction + _YAW_OFFSETS["T1"]) % 360.0, + _NORTHED_YAW: wind_direction % 360.0, }, index=idx, ) @@ -721,6 +735,8 @@ def _shrinkage_scada(n: int, *, uplift: float, treated: np.ndarray, seed: int = _AVAIL: 600.0, _WS: w, _WS_SD: 0.05 * w, + _YAW: (wind_direction + _YAW_OFFSETS[f"R{i}"]) % 360.0, + _NORTHED_YAW: wind_direction % 360.0, }, index=idx, ) diff --git a/tests/benchmarking/campaigns/test_northing.py b/tests/benchmarking/harness/test_northing.py similarity index 59% rename from tests/benchmarking/campaigns/test_northing.py rename to tests/benchmarking/harness/test_northing.py index 7ed93741..01a52ab1 100644 --- a/tests/benchmarking/campaigns/test_northing.py +++ b/tests/benchmarking/harness/test_northing.py @@ -6,9 +6,9 @@ import pandas as pd import pytest -from benchmarking.campaigns.declaration import CampaignSpec -from benchmarking.campaigns.northing import north_campaign_scada -from benchmarking.synthetic import HOT_COLUMNS +from benchmarking.harness.northing import north_scada +from benchmarking.harness.replicates import StudyConfig, iter_replicates +from benchmarking.synthetic import HOT_COLUMNS, ConstantCpChange from wind_up.circular_math import circ_diff _COLUMNS = HOT_COLUMNS @@ -51,17 +51,10 @@ def _scada( return pd.concat(frames), site_wd -def _spec(north_offsets: list[tuple[str, pd.Timestamp, float]] | None) -> CampaignSpec: - return CampaignSpec( - upgraded_turbines=["T01"], - upgrade_timing=_START + pd.Timedelta(days=60), - candidate_references=[t for t in _TURBINES if t != "T01"], - excluded_turbines=[], - coords=dict.fromkeys(_TURBINES, (0.0, 0.0)), - north_offsets=north_offsets, - rated_power_kw=_RATED, - analysis_period=(_START, _START + pd.Timedelta(days=120)), - ) +def _spread_across_turbines(directions: np.ndarray) -> float: + """Median absolute disagreement between each turbine's direction and the first turbine's.""" + reference = np.repeat(directions[:, [0]], directions.shape[1], axis=1) + return float(np.nanmedian(np.abs(circ_diff(directions, reference)))) def _northed(frame: pd.DataFrame, turbine: str) -> np.ndarray: @@ -78,7 +71,7 @@ def test_writes_a_northed_companion_leaving_the_original_untouched(self) -> None scada, site_wd = _scada(index, offsets) era5 = pd.Series(site_wd, index=index) - out = north_campaign_scada(scada, spec=_spec(None), columns=_COLUMNS, era5_wd=era5) + out = north_scada(scada, columns=_COLUMNS, north_offsets=None, rated_power_kw=_RATED, era5_wd=era5) assert _COLUMNS.northed("nacelle_position") in out.columns assert np.allclose( @@ -92,7 +85,7 @@ def test_recovers_each_turbines_offset(self) -> None: scada, site_wd = _scada(index, offsets) era5 = pd.Series(site_wd, index=index) - out = north_campaign_scada(scada, spec=_spec(None), columns=_COLUMNS, era5_wd=era5) + out = north_scada(scada, columns=_COLUMNS, north_offsets=None, rated_power_kw=_RATED, era5_wd=era5) for turbine in _TURBINES: assert circ_diff(_northed(out, turbine), site_wd).mean() == pytest.approx(0.0, abs=2.0), turbine @@ -105,7 +98,7 @@ def test_recovers_a_step_change_mid_campaign(self) -> None: scada, site_wd = _scada(index, offsets) era5 = pd.Series(site_wd, index=index) - out = north_campaign_scada(scada, spec=_spec(None), columns=_COLUMNS, era5_wd=era5) + out = north_scada(scada, columns=_COLUMNS, north_offsets=None, rated_power_kw=_RATED, era5_wd=era5) assert circ_diff(_northed(out, "T03"), site_wd).mean() == pytest.approx(0.0, abs=2.0) @@ -113,7 +106,7 @@ def test_discovery_without_reanalysis_raises(self) -> None: index = _index(days=30) scada, _ = _scada(index, {t: [(_START, 0.0)] for t in _TURBINES}) with pytest.raises(ValueError, match="era5_wd"): - north_campaign_scada(scada, spec=_spec(None), columns=_COLUMNS, era5_wd=None) + north_scada(scada, columns=_COLUMNS, north_offsets=None, rated_power_kw=_RATED, era5_wd=None) class TestDeclared: @@ -124,7 +117,7 @@ def test_applies_the_declared_offsets(self) -> None: scada, _ = _scada(index, {t: [(_START, 0.0)] for t in _TURBINES}) declared = [("T02", _START, 33.0)] - out = north_campaign_scada(scada, spec=_spec(declared), columns=_COLUMNS, era5_wd=None) + out = north_scada(scada, columns=_COLUMNS, north_offsets=declared, rated_power_kw=_RATED, era5_wd=None) raw = scada[scada[_COLUMNS.turbine] == "T02"][_COLUMNS.nacelle_position].to_numpy(dtype=float) assert _northed(out, "T02") == pytest.approx((raw + 33.0) % 360.0) @@ -137,7 +130,7 @@ def test_an_empty_list_applies_no_correction_but_still_writes_the_column(self) - offsets = {t: [(_START, 30.0)] for t in _TURBINES} scada, _ = _scada(index, offsets) - out = north_campaign_scada(scada, spec=_spec([]), columns=_COLUMNS, era5_wd=None) + out = north_scada(scada, columns=_COLUMNS, north_offsets=[], rated_power_kw=_RATED, era5_wd=None) assert _COLUMNS.northed("nacelle_position") in out.columns for turbine in _TURBINES: @@ -148,4 +141,58 @@ def test_an_empty_list_needs_no_reanalysis(self) -> None: index = _index(days=30) scada, _ = _scada(index, {t: [(_START, 0.0)] for t in _TURBINES}) # would raise if this branch tried to discover - north_campaign_scada(scada, spec=_spec([]), columns=_COLUMNS, era5_wd=None) + north_scada(scada, columns=_COLUMNS, north_offsets=[], rated_power_kw=_RATED, era5_wd=None) + + +class TestStudyPath: + """The study path norths every replicate, so a method sees a table wind-up worked out itself.""" + + @staticmethod + def _study() -> StudyConfig: + return StudyConfig( + mode="prepost", + turbine_subset=list(_TURBINES), + treatment_start_range=(_START + pd.Timedelta(days=55), _START + pd.Timedelta(days=65)), + min_pre_months=1, + campaign_months=[1], + n_replicates=1, + seed=0, + ) + + @staticmethod + def _replicate(*, era5_wd: pd.Series | None) -> pd.DataFrame: + index = _index() + offsets = {t: [(_START, 25.0 * i)] for i, t in enumerate(_TURBINES)} + scada, site_wd = _scada(index, offsets) + reanalysis = pd.Series(site_wd, index=index) if era5_wd is None else era5_wd + replicates = list( + iter_replicates( + scada, + profile=[ConstantCpChange(delta=0.05)], + study=TestStudyPath._study(), + columns=_COLUMNS, + era5_wd=None if era5_wd is None else reanalysis, + rated_power_kw=_RATED, + ) + ) + return replicates[0].synthetic_df + + def test_no_reanalysis_means_no_northed_column(self) -> None: + assert _COLUMNS.northed("nacelle_position") not in self._replicate(era5_wd=None).columns + + def test_reanalysis_norths_each_replicate(self) -> None: + index = _index() + _, site_wd = _scada(index, {t: [(_START, 25.0 * i)] for i, t in enumerate(_TURBINES)}) + synthetic = self._replicate(era5_wd=pd.Series(site_wd, index=index)) + + assert _COLUMNS.northed("nacelle_position") in synthetic.columns + # Each turbine reports the site direction behind its own offset; northing removes the + # spread between them, which the raw readings still carry. + raw = np.column_stack( + [ + synthetic[synthetic[_COLUMNS.turbine] == t][_COLUMNS.nacelle_position].to_numpy(dtype=float) + for t in _TURBINES + ] + ) + northed = np.column_stack([_northed(synthetic, t) for t in _TURBINES]) + assert _spread_across_turbines(northed) < 0.25 * _spread_across_turbines(raw) From 9219d92b4f13eaa38c6677a267a2a38e48b6b5e2 Mon Sep 17 00:00:00 2001 From: aclerc Date: Thu, 3 Sep 2026 17:02:55 +0100 Subject: [PATCH 10/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- src/wind_up/circular_math.py | 75 +++++---------- src/wind_up/northing.py | 96 ++++++------------- src/wind_up/northing_plots.py | 24 +++-- .../test_circular_math.py} | 24 ++++- tests/{ => wind_up}/test_rolling_circ_mean.py | 2 +- .../{ => wind_up}/test_rolling_circ_median.py | 4 +- 6 files changed, 92 insertions(+), 133 deletions(-) rename tests/{test_math_funcs.py => wind_up/test_circular_math.py} (87%) rename tests/{ => wind_up}/test_rolling_circ_mean.py (98%) rename tests/{ => wind_up}/test_rolling_circ_median.py (96%) diff --git a/src/wind_up/circular_math.py b/src/wind_up/circular_math.py index c96dc355..f18629f6 100644 --- a/src/wind_up/circular_math.py +++ b/src/wind_up/circular_math.py @@ -8,69 +8,44 @@ from scipy.stats import circmean -def circ_diff(angle1: float | npt.NDArray | list, angle2: float | npt.NDArray | list) -> float | npt.NDArray: - """Calculate the circular difference between two angles. +def circ_diff(angle1: npt.ArrayLike, angle2: npt.ArrayLike) -> npt.NDArray[np.float64] | np.float64: + """Signed circular difference ``angle1 - angle2`` in degrees, wrapped to [-180, 180). - :param angle1: First angle in degrees. - :param angle2: Second angle in degrees. - :return: Circular difference between the two angles in degrees + :param angle1: first angle in degrees + :param angle2: second angle in degrees + :return: the wrapped difference in degrees """ - # Convert list to numpy array - if isinstance(angle1, list): - angle1 = np.array(angle1) - if isinstance(angle2, list): - angle2 = np.array(angle2) + return np.mod(np.subtract(angle1, angle2) + 180, 360) - 180 - return np.mod(angle1 - angle2 + 180, 360) - 180 +def circ_median( + angles: npt.ArrayLike, axis: int | None = None, *, range_360: bool = True +) -> npt.NDArray[np.float64] | np.float64: + """Circular median of angles in degrees, approximated by centring on the circular mean. -def circ_median(angles: npt.NDArray, axis: int | None = None, *, range_360: bool = True) -> float | npt.NDArray: - """Calculate the circular median of angles. + NaNs are dropped; an input with no finite values gives NaN. Input may be in any range. - Uses an efficient approximation: centers data around the circular mean, - computes ordinary median, then rotates back. - - :param angles: Array of angles in degrees. Can be a numpy array, list, or pandas Series. - Input can be in any range; it will be normalized internally. - :param axis: Axis along which to compute the median. If None, compute over flattened array. - :param range_360: If True, return result in [0, 360). If False, return result in [-180, 180). - :return: Circular median in degrees + :param angles: angles in degrees + :param axis: axis to reduce over; ``None`` reduces the flattened input + :param range_360: return in [0, 360) rather than [-180, 180) + :return: the circular median in degrees """ - # Convert to numpy array (handles lists, Series, etc.) - angles = np.asarray(angles) - - # Handle axis parameter + values = np.asarray(angles) if axis is not None: - return np.apply_along_axis(lambda x: circ_median(x, axis=None, range_360=range_360), axis, angles) - - # Flatten if needed - angles = angles.flatten() - - # Remove NaN values - angles = angles[~np.isnan(angles)] - - if len(angles) == 0: - return np.nan - - # Normalize angles to [0, 360) for computation - angles_normalized = np.mod(angles, 360) - - # Calculate circular mean (in radians for scipy, convert back to degrees) - mean_angle = circmean(angles_normalized, high=360, low=0) - - # Center the data around 180 (subtract mean, add 180) - centered_angles = np.mod(angles_normalized - mean_angle + 180, 360) + return np.apply_along_axis(lambda x: circ_median(x, axis=None, range_360=range_360), axis, values) - # Compute ordinary median on centered data - median_centered = np.median(centered_angles) + values = values.flatten() + values = values[~np.isnan(values)] + if len(values) == 0: + return np.float64(np.nan) - # Rotate back (subtract 180, add mean back) - median_angle = np.mod(median_centered - 180 + mean_angle, 360) + normalized = np.mod(values, 360) + mean_angle = circmean(normalized, high=360, low=0) + centered = np.mod(normalized - mean_angle + 180, 360) + median_angle = np.mod(np.median(centered) - 180 + mean_angle, 360) - # Convert to requested range if range_360: return median_angle - # Convert to [-180, 180) return np.mod(median_angle + 180, 360) - 180 diff --git a/src/wind_up/northing.py b/src/wind_up/northing.py index d6ba9b26..b5691fa6 100644 --- a/src/wind_up/northing.py +++ b/src/wind_up/northing.py @@ -1,21 +1,14 @@ """Estimate and apply north-calibration corrections for a direction signal. -A turbine's reported yaw direction carries an unknown offset from true north that changes in -**steps** when the sensor is recalibrated or replaced. :func:`estimate_north_table` recovers -those steps by comparing the signal with a reference direction, and returns a table of -``(timestamp, north_offset)`` that :func:`apply_north_table` steps onto the raw signal. - -Offsets are always **absolute** -- relative to the raw field, never to an already-corrected -one -- so a supplied table and an estimated one are directly comparable and repeated runs -compose. +:func:`estimate_north_table` compares a direction signal with a reference and returns a table of +``(timestamp, north_offset)`` describing the steps it found; :func:`apply_north_table` steps that +table onto the raw signal. Offsets are absolute -- relative to the raw field, never to an +already-corrected one -- so a supplied table and an estimated one are directly comparable. :func:`north_farm` runs the two-pass farm workflow: north every device to reanalysis, build a -farm consensus direction from the results, then north every device to that. The second pass is -the more precise one; the first is what anchors the farm in absolute terms, without which a -farm that is uniformly wrong looks perfectly self-consistent. +farm consensus direction from the results, then north every device to that. -The estimator works on any direction field. Only :func:`yaw_usable` is turbine-specific -- a -mast or LiDAR needs a wind-speed-based mask instead, which is not wired up yet. +The estimator works on any direction field. Only :func:`yaw_usable` is turbine-specific. """ from __future__ import annotations @@ -42,13 +35,10 @@ TIMESTAMP_COL = "timestamp" NORTH_OFFSET_COL = "north_offset" -# A turbine's yaw reading is only meaningful when it is generating; below this fraction of -# rated power it often points away from the wind. +# Yaw is read only above this fraction of rated power. YAW_OK_POWER_FRACTION = 0.05 -# Above this many aggregation bins the cost matrix gets large (it is O(bins^2)); warn rather -# than fail, since the result is still correct. +# Above this many aggregation bins the search warns; it still returns a correct result. _BIN_COUNT_WARN = 3000 -# Search-shape defaults, as constants so the dataclass defaults are not function calls. _DEFAULT_GRID = pd.Timedelta(days=1) _DEFAULT_MIN_SEGMENT = pd.Timedelta(days=7) # A segment needs a row either side of a candidate split for the split to mean anything. @@ -57,14 +47,12 @@ _DEFAULT_VEER_SECTOR_DEG = 30.0 # A sector with fewer usable rows than this has no trustworthy level of its own. _MIN_ROWS_PER_SECTOR = 50 -# A step larger than this is a recalibration whatever else the record does, so it is never ironed -# out as wander -- real ones do sometimes reverse later. +# Steps larger than this are never ironed out as wander. _MAX_TRANSIENT_STEP_DEG = 10.0 -# The span either side of a changepoint at which ``min_step_deg`` applies unmodified. With less -# record than this the level is veer-limited rather than sample-limited, so a bigger step is -# needed to tell a recalibration from the wander. +# The span either side of a changepoint at which ``min_step_deg`` applies unmodified; a shorter +# segment needs a larger step. _DEFAULT_CONFIDENT_SEGMENT = pd.Timedelta(days=90) @@ -110,20 +98,13 @@ class NorthingSettings: confident_segment: pd.Timedelta = _DEFAULT_CONFIDENT_SEGMENT -# Reanalysis is a modelled, drift-prone direction: a shift in it looks exactly like a shift in -# every turbine at once, so only large steps may be attributed to a turbine against it. A farm -# consensus shares that common-mode error, so against one a residual step really is the -# turbine's. See :func:`against_reanalysis`. +# Minimum step attributable to a turbine when northing against reanalysis rather than a farm +# consensus. See :func:`against_reanalysis`. REANALYSIS_MIN_STEP_DEG = 10.0 -# The first pass may only act on a *gross* recalibration -- one large enough that leaving it -# uncorrected would drag the farm consensus the second pass depends on. Reanalysis' own -# direction-dependent bias moves every turbine together by up to ~20 degrees during a spell of -# unusual wind, so the bar sits above that. +# Minimum step the first pass may act on. See :func:`anchoring_only`. ANCHORING_MIN_STEP_DEG = 30.0 -# Only a step this large is taken out of the residual before the veer signature is measured. The -# de-stepping exists so a real recalibration cannot leak into the signature, but a speculative -# split takes the sector level with it -- and the signature is then measured on a residual that no -# longer carries the veer it is meant to describe. See :func:`_confident_steps`. +# Minimum step taken out of the residual before the veer signature is measured. +# See :func:`_confident_steps`. VEER_SIGNATURE_MIN_STEP_DEG = 10.0 DEFAULT_NORTHING = NorthingSettings() @@ -132,15 +113,8 @@ class NorthingSettings: def anchoring_only(settings: NorthingSettings) -> NorthingSettings: """Return ``settings`` reduced to what the first pass is for: anchoring, not changepoint work. - The first pass exists to fix the farm in absolute terms against reanalysis. Reanalysis has its - own direction-dependent bias, so a spell of unusual wind moves every turbine's residual - against it together, by tens of degrees -- and acting on that writes the artefact into the - corrected directions and from there into the farm consensus the second pass trusts. - - So only a **gross** step is acted on here (:data:`ANCHORING_MIN_STEP_DEG`): large enough that - leaving it would drag the consensus, and larger than reanalysis' own excursions. Everything - finer is left to the second pass, which works against the farm consensus and estimates from - the **raw** direction, so nothing is lost by deferring it. + Only steps of at least :data:`ANCHORING_MIN_STEP_DEG` are acted on; finer structure is left + to the second pass, which works against the farm consensus. """ return replace(settings, min_step_deg=ANCHORING_MIN_STEP_DEG) @@ -251,10 +225,9 @@ def veer_normalised( level without anything at the turbine changing, and a changepoint search reads that as a step. Subtracting each sector's whole-record median removes it: a genuine north offset shifts every - sector alike and so survives, while a change in the mix cannot move the level at all. Sectors - with too little data fall back to the overall level. + sector alike and so survives. Sectors with too little data fall back to the overall level. - Use this for **detection only** -- segment offsets are estimated from the raw residual, so the + Use this for detection only -- segment offsets are estimated from the raw residual, so the correction stays absolute. :param de_stepped: the residual with a first-pass estimate of the step structure removed. The @@ -528,10 +501,9 @@ def _worst_transient( min_step_deg: float, max_transient_step_deg: float, ) -> int | None: - """Return the least persistent **small** changepoint -- site veer wandering away and back. + """Return the least persistent small changepoint -- site veer wandering away and back. - Steps larger than ``max_transient_step_deg`` are never named: a real recalibration is - sometimes reversed later, and its size is the evidence that it happened. + Steps larger than ``max_transient_step_deg`` are never named. """ edges = [start, *changepoints, end] durations = np.array([max((b - a).total_seconds(), 1.0) for a, b in itertools.pairwise(edges)], dtype=float) @@ -605,7 +577,7 @@ def estimate_north_table( Compares ``direction_deg`` with ``reference_deg`` over the rows ``usable`` allows, finds the step changes in their circular difference, and returns the offset that corrects each - resulting period. Offsets are absolute: adding one to the **raw** signal norths it. + resulting period. Offsets are absolute: adding one to the raw signal norths it. :param index: timestamps of every array; need not be sorted :param direction_deg: the signal to north, in degrees @@ -688,12 +660,9 @@ def normalised(de_stepped: npt.NDArray[np.float64] | None) -> npt.NDArray[np.flo de_stepped=de_stepped, ) - # Search in the veer-normalised residual, so a shift in the direction mix cannot look like - # a step. The signature is measured twice: first assuming no step structure, then around - # the confident steps that search found. Measuring it around a *speculative* split instead - # would remove the sector level along with the split, leaving the veer in place and the - # split with it. Offsets come from the raw residual either way, so the correction stays - # absolute. + # Search the veer-normalised residual, measuring the sector signature twice: first assuming + # no step structure, then around only the steps that search was confident of. Offsets come + # from the raw residual either way, so the correction stays absolute. provisional = detect(normalised(None)) confident = _confident_steps(provisional, start=start, residual=residual, index=index) changepoints = ( @@ -731,9 +700,8 @@ def apply_north_table( """North a direction signal: ``(direction + offset) % 360``, offsets step-applied. Each row of ``north_table`` holds from its timestamp until the next; rows before the first - timestamp take the first offset. Takes a single array so **one table can north several - fields of the same device** -- derive the correction from yaw position, then apply it to - yaw position and to a measured wind-direction channel. NaNs are preserved. + timestamp take the first offset. Takes a single array, so one table can north several + fields of the same device. NaNs are preserved. """ index = pd.DatetimeIndex(index) direction = np.asarray(direction_deg, dtype=float) @@ -764,8 +732,6 @@ def _median_across(stack: npt.NDArray[np.float64], *, enough: npt.NDArray[np.boo return farm -# A consensus needs a strict majority of the farm reporting. Below that the median is over an -# unrepresentative few, whose own veer moves the reference rather than the farm's. def _farm_quorum(n_devices: int, *, floor: int) -> int: """Return how many devices must report for their median to stand for the farm's consensus.""" return max(floor, n_devices // 2 + 1) @@ -803,10 +769,8 @@ def north_farm( """North a whole farm in two passes, returning one absolute table per device. Pass 1 norths each device to ``reanalysis_deg``; the northed directions give a farm - consensus direction, and pass 2 norths each device's **raw** signal to that. Pass 2 is the - more precise of the two, but pass 1 is what fixes the farm in absolute terms: a farm whose - devices are all wrong by the same amount agrees with itself perfectly, so a farm-relative - pass alone cannot see it. + consensus direction, and pass 2 norths each device's raw signal to that. Pass 1 is what + fixes the farm in absolute terms; pass 2 is the more precise. Every device's arrays are positional on the shared ``index``, which is what lets the farm consensus be taken across devices at each timestamp. diff --git a/src/wind_up/northing_plots.py b/src/wind_up/northing_plots.py index 88f24e9c..27d64394 100644 --- a/src/wind_up/northing_plots.py +++ b/src/wind_up/northing_plots.py @@ -1,13 +1,11 @@ """Show what the northing estimator did, so a user can judge it rather than trust it. -Two views per device, because a northing error and site veer look alike in either one alone: - -* **over time** -- the residual against the reference, time-averaged so veer is smeared out, - before and after correction, with the fitted step function and its changepoints drawn on. This - is the view that answers "is the corrected direction believable to a degree?". -* **against direction** -- the same residual binned by the reference direction. What is left - after correction is site veer: the wind direction genuinely differs across a site, and no - north offset can remove it. A tilt here is expected; a vertical shift is not. +Two views per device, since a northing error and site veer look alike in either one alone: + +* over time -- the residual against the reference, time-averaged, before and after correction, + with the fitted step function and its changepoints drawn on. +* against direction -- the same residual binned by the reference direction. What is left after + correction is site veer: a tilt here is expected, a vertical shift is not. """ from __future__ import annotations @@ -75,7 +73,7 @@ def plot_northing( """Plot one device's northing: the residual over time, and against direction. :param index: timestamps of every array - :param direction_deg: the **raw** direction signal, before correction + :param direction_deg: the raw direction signal, before correction :param reference_deg: the direction it was northed against :param usable: the rows the estimate was allowed to use :param north_table: the estimated table, as returned by @@ -250,11 +248,11 @@ def plot_residual_conditions( ) -> Figure: """Mean and spread of the northing residual against direction, wind speed and power. - The question these answer is whether the residual should be **weighted**: if its spread - blows up at low power or low wind speed, those records tell you less about where north is - and should count for less. A flat spread says an unweighted estimate is fine. + Shows whether the residual should be weighted: a spread that blows up at low power or low + wind speed says those records count for less, a flat spread that an unweighted estimate is + fine. - Pass the residual **after** northing, over the rows the estimate was allowed to use. + Pass the residual after northing, over the rows the estimate was allowed to use. """ fraction = np.asarray(power, dtype=float) / rated_power panels = ( diff --git a/tests/test_math_funcs.py b/tests/wind_up/test_circular_math.py similarity index 87% rename from tests/test_math_funcs.py rename to tests/wind_up/test_circular_math.py index e11ac7be..8e15b351 100644 --- a/tests/test_math_funcs.py +++ b/tests/wind_up/test_circular_math.py @@ -6,7 +6,7 @@ from pandas.testing import assert_series_equal from scipy.stats import circmean -from wind_up_v0.circular_math import circ_diff, circ_median +from wind_up.circular_math import circ_diff, circ_median test_circ_diff_data = [ (0, 0, 0), @@ -184,3 +184,25 @@ def test_circ_median_range_conversion() -> None: # They should represent the same angle abs_circ_distance = abs(circ_diff(result_360, result_180)) assert abs_circ_distance < 1e-3 + + +@pytest.mark.parametrize("range_360", [True, False]) +def test_circ_median_along_axis(*, range_360: bool) -> None: + """Reducing along an axis matches reducing each slice, and stays circular.""" + # the first row straddles 0/360, where an ordinary median would land near 180 + angles = np.array([[350.0, 355.0, 5.0, 10.0], [90.0, 92.0, 94.0, 96.0]]) + + result = circ_median(angles, axis=1, range_360=range_360) + + assert result == pytest.approx([0.0, 93.0]) + rowwise = [circ_median(row, range_360=range_360) for row in angles] + assert result == pytest.approx(rowwise) + + +def test_circ_median_along_axis_zero() -> None: + """``axis=0`` reduces down the columns.""" + angles = np.array([[350.0, 355.0], [10.0, 5.0]]) + + result = circ_median(angles, axis=0, range_360=False) + + assert result == pytest.approx([0.0, 0.0]) diff --git a/tests/test_rolling_circ_mean.py b/tests/wind_up/test_rolling_circ_mean.py similarity index 98% rename from tests/test_rolling_circ_mean.py rename to tests/wind_up/test_rolling_circ_mean.py index c9b51ab0..7834ef19 100644 --- a/tests/test_rolling_circ_mean.py +++ b/tests/wind_up/test_rolling_circ_mean.py @@ -6,7 +6,7 @@ from pandas.testing import assert_series_equal from scipy.stats import circmean -from wind_up_v0.circular_math import rolling_circ_mean +from wind_up.circular_math import rolling_circ_mean @pytest.mark.parametrize("range_360", [True, False]) diff --git a/tests/test_rolling_circ_median.py b/tests/wind_up/test_rolling_circ_median.py similarity index 96% rename from tests/test_rolling_circ_median.py rename to tests/wind_up/test_rolling_circ_median.py index 355bdd92..c871baee 100644 --- a/tests/test_rolling_circ_median.py +++ b/tests/wind_up/test_rolling_circ_median.py @@ -5,8 +5,8 @@ import pytest from pandas.testing import assert_series_equal -from tests.test_math_funcs import circ_median_exact -from wind_up_v0.circular_math import circ_diff, rolling_circ_median_approx +from tests.wind_up.test_circular_math import circ_median_exact +from wind_up.circular_math import circ_diff, rolling_circ_median_approx @pytest.mark.parametrize("range_360", [True, False]) From 4fbbc87389b961d64c0afe8a3ab385986d1bef01 Mon Sep 17 00:00:00 2001 From: aclerc Date: Thu, 3 Sep 2026 17:37:13 +0100 Subject: [PATCH 11/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- .../baselines/old/inspect_short_campaigns.py | 168 ------------------ benchmarking/campaigns/runner.py | 31 ++-- benchmarking/harness/northing.py | 13 +- benchmarking/synthetic/sources/greenbyte.py | 5 +- tests/benchmarking/harness/test_northing.py | 18 ++ tests/wind_up/test_northing_real_data.py | 12 +- 6 files changed, 56 insertions(+), 191 deletions(-) delete mode 100644 benchmarking/baselines/old/inspect_short_campaigns.py diff --git a/benchmarking/baselines/old/inspect_short_campaigns.py b/benchmarking/baselines/old/inspect_short_campaigns.py deleted file mode 100644 index bd3d4fd5..00000000 --- a/benchmarking/baselines/old/inspect_short_campaigns.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Short-campaign (1-2 month) exploration: check whether the Issue 9-13 accepted choices hold there. - -The committed benchmark sweeps 3-12 month campaigns; this driver scores 1- and 2-month campaigns -(outside the benchmark grid, so no reference-run merge — oracle + naive anchor the numbers instead -of v0, which is slow) and A/Bs the regime-dependent choices at those lengths. - -Which choices can even flip at short campaigns: - -* **prepost** — a shorter campaign shrinks only the *prediction* window; the training set (the - full pre-changeover baseline) is unchanged, so the fit-side choices (capacity, features) cannot - flip. Only the time-decay weights act on the training side, so prepost trials just those. -* **toggle** — the campaign length scales the training data itself, so capacity - (``min_child_samples``) and the decay weights are genuinely in play. - -Run from the repo root (defaults: both modes, all variants):: - - uv run python -m benchmarking.baselines.old.inspect_short_campaigns - -Outputs one ``results___.csv`` per run plus a combined -``short_campaign_summary.csv`` / log table of power_model bias/spread/score per -``(mode, variant, campaign_months)`` under ``--output-dir`` -(default ``~/temp/wind-up-benchmarking/short_campaigns``). -""" - -from __future__ import annotations - -import argparse -import logging -from pathlib import Path -from typing import Any, Literal, cast - -import pandas as pd - -from benchmarking.baselines.example_prepost_study import ( - DEFAULT_END_DT_EXCL, - DEFAULT_START_DT, - DEFAULT_TREATMENT_START_RANGE, - DEFAULT_TURBINE_SUBSET, - DEFAULT_WTG_NUMBERS, - MIN_PRE_MONTHS, -) -from benchmarking.baselines.example_toggle_study import DEFAULT_TOGGLE_PERIOD -from benchmarking.baselines.hot_context import build_hot_v0_context -from benchmarking.baselines.naive_ratio import NaiveRatioMethod -from benchmarking.baselines.overnight_profiles import overnight_profiles -from benchmarking.baselines.study_power_model_compare import _make_power_model -from benchmarking.harness import Method, StudyConfig, leaderboard, score_study -from benchmarking.harness.example_hot_study import OracleMethod -from benchmarking.synthetic import HOT_COLUMNS -from benchmarking.synthetic.sources.hill_of_towie import load_hot_scada - -logger = logging.getLogger(__name__) - -CAMPAIGN_MONTHS = [1, 2] -N_REPLICATES = 4 -SEED = 0 -PROFILES = ("cp_0pct", "cp_plus_3pct") # placebo (bias/spread) + a plain recovery check -_DEFAULT_OUTPUT_DIR = Path.home() / "temp" / "wind-up-benchmarking" / "short_campaigns" - -# Variant name -> (modes it is meaningful for, PowerModelMethod overrides). The "default" anchor -# also scores oracle + naive for context. See the module docstring for why prepost trials only -# the decay weights. -VARIANTS: dict[str, tuple[tuple[str, ...], dict[str, Any]]] = { - "default": (("prepost", "toggle"), {}), - "hl90": (("prepost", "toggle"), {"adaptive_time_decay": False, "time_decay_half_life_days": 90}), - "hl365": (("prepost", "toggle"), {"adaptive_time_decay": False, "time_decay_half_life_days": 365}), - "mcs200": (("toggle",), {"model_params": {"min_child_samples": 200}}), -} - - -def _study(mode: str) -> StudyConfig: - return StudyConfig( - mode=cast("Literal['prepost', 'toggle']", mode), - turbine_subset=DEFAULT_TURBINE_SUBSET, - treatment_start_range=DEFAULT_TREATMENT_START_RANGE, - min_pre_months=MIN_PRE_MONTHS, - campaign_months=CAMPAIGN_MONTHS, - toggle_period=DEFAULT_TOGGLE_PERIOD if mode == "toggle" else None, - n_replicates=N_REPLICATES, - seed=SEED, - ) - - -def run_variant( - mode: str, - variant: str, - out_dir: Path, - *, - scada_df: pd.DataFrame, - era5_hourly_df: pd.DataFrame, - profiles: dict[str, list], -) -> pd.DataFrame: - """Score one (mode, variant) over the short-campaign grid; anchor methods only on ``default``.""" - overrides = VARIANTS[variant][1] - frames = [] - for profile_name, profile in profiles.items(): - methods: list[Method] = [] - if variant == "default": - methods.append(OracleMethod(scada_df)) - methods.append( - NaiveRatioMethod( - columns=HOT_COLUMNS, - out_dir=out_dir / "naive_runs", - ) - ) - methods.append( - _make_power_model(out_dir / variant / profile_name, era5_hourly_df=era5_hourly_df, overrides=overrides) - ) - logger.info("Scoring %s / %s / %s", mode, variant, profile_name) - results = score_study(scada_df, profile=profile, methods=methods, study=_study(mode), profile_name=profile_name) - results.to_csv(out_dir / f"results_{mode}_{variant}_{profile_name}.csv", index=False) - frames.append(results.assign(variant=variant, mode=mode)) - return pd.concat(frames, ignore_index=True) - - -def summarise(all_results: pd.DataFrame, out_dir: Path) -> None: - """Write/log power_model bias/spread/score per (mode, variant, profile, campaign) + the anchors.""" - rows = [] - for (mode, variant), chunk in all_results.groupby(["mode", "variant"]): - lb = leaderboard(chunk) - lb = lb.assign(mode=mode, variant=variant) - rows.append(lb) - summary = pd.concat(rows, ignore_index=True) - cols = ["mode", "variant", "method", "profile", "campaign_months", "bias", "spread", "score"] - summary = summary[cols].sort_values(["mode", "profile", "campaign_months", "method", "variant"]) - summary.to_csv(out_dir / "short_campaign_summary.csv", index=False) - show = summary.copy() - for col in ("bias", "spread", "score"): - show[col] = (100 * show[col]).round(3) - logger.info("Short-campaign summary [pp]:\n%s", show.to_string(index=False)) - - -def main() -> None: - """Run the short-campaign exploration for the requested modes/variants.""" - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--modes", nargs="+", choices=["prepost", "toggle"], default=["prepost", "toggle"]) - parser.add_argument("--variants", nargs="+", choices=sorted(VARIANTS), default=None) - parser.add_argument("--output-dir", type=Path, default=_DEFAULT_OUTPUT_DIR) - args = parser.parse_args() - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", force=True) - - out_dir = args.output_dir.expanduser() - out_dir.mkdir(parents=True, exist_ok=True) - scada_df, _ = load_hot_scada( - start_dt=DEFAULT_START_DT, - end_dt_excl=DEFAULT_END_DT_EXCL, - wtg_numbers=DEFAULT_WTG_NUMBERS, - wtg_names=DEFAULT_TURBINE_SUBSET, - ) - context = build_hot_v0_context(wtg_names=DEFAULT_TURBINE_SUBSET) - era5 = context.reanalysis_datasets[0].data - profiles = {name: overnight_profiles()[name] for name in PROFILES} - - all_results = [] - for mode in args.modes: - for variant, (variant_modes, _) in VARIANTS.items(): - if args.variants is not None and variant not in args.variants: - continue - if mode not in variant_modes: - continue - all_results.append( - run_variant(mode, variant, out_dir, scada_df=scada_df, era5_hourly_df=era5, profiles=profiles) - ) - summarise(pd.concat(all_results, ignore_index=True), out_dir) - - -if __name__ == "__main__": - main() diff --git a/benchmarking/campaigns/runner.py b/benchmarking/campaigns/runner.py index ce3780a2..20eb6932 100644 --- a/benchmarking/campaigns/runner.py +++ b/benchmarking/campaigns/runner.py @@ -70,10 +70,9 @@ class CampaignRunner: :param spec: the public campaign facts; methods see nothing else :param dataset: the generated dataset, whose ``original_df`` supplies the truth :param build_methods: given an upgraded turbine's name, the methods to run for it - :param era5_wd: reanalysis wind direction covering the campaign. Supplying it turns on the - shared northing step, which writes a ``northed_`` companion for each direction role - upstream of every method. Required when ``spec.north_offsets`` is ``None`` and northing - is wanted; without it the step is skipped and methods see no northed column. + :param era5_wd: reanalysis wind direction covering the campaign, the anchor the shared northing + step discovers against. Required when ``spec.north_offsets`` is ``None``; a declared table + needs none. :param northing_roles: the direction roles the shared step corrects :param northing_settings: how the shared step's changepoint search is bounded """ @@ -196,27 +195,21 @@ def _visible_dataset(self) -> SyntheticDataset: """ synthetic = self._dataset.synthetic_df keep = self._visible_mask(synthetic) - visible = synthetic[keep] - if self._should_north(): - visible = north_scada( - visible, - columns=self._dataset.columns, - north_offsets=self._spec.north_offsets, - rated_power_kw=self._spec.rated_power_kw, - era5_wd=self._era5_wd, - roles=self._northing_roles, - settings=self._northing_settings, - ) + visible = north_scada( + synthetic[keep], + columns=self._dataset.columns, + north_offsets=self._spec.north_offsets, + rated_power_kw=self._spec.rated_power_kw, + era5_wd=self._era5_wd, + roles=self._northing_roles, + settings=self._northing_settings, + ) return replace( self._dataset, synthetic_df=visible, original_df=self._dataset.original_df[self._visible_mask(self._dataset.original_df)], ) - def _should_north(self) -> bool: - """Whether the shared step can run: a declared table needs nothing, discovery needs ERA5.""" - return self._spec.north_offsets is not None or self._era5_wd is not None - def _visible_mask(self, frame: pd.DataFrame) -> np.ndarray: """Rows of ``frame`` inside the analysis period whose turbine may be used.""" spec = self._spec diff --git a/benchmarking/harness/northing.py b/benchmarking/harness/northing.py index c2b87b79..82ec1d91 100644 --- a/benchmarking/harness/northing.py +++ b/benchmarking/harness/northing.py @@ -46,10 +46,21 @@ def era5_direction(era5_df: pd.DataFrame, index: pd.DatetimeIndex) -> pd.Series: def _north_table_from_offsets( offsets: Sequence[tuple[str, pd.Timestamp, float]], *, turbine: str, start: pd.Timestamp ) -> pd.DataFrame: - """Return one turbine's declared north table, or a zero-offset table when none is declared.""" + """Return one turbine's declared north table, or a zero-offset table when none is declared. + + :raises ValueError: if the turbine's first declared offset begins after ``start``, which would + leave the earliest rows to be corrected by a later offset + """ rows = sorted(((ts, off) for (t, ts, off) in offsets if t == turbine), key=lambda e: e[0]) if not rows: return pd.DataFrame({"timestamp": pd.DatetimeIndex([start]), "north_offset": [0.0]}) + if rows[0][0] > start: + msg = ( + f"the declared north offsets for {turbine!r} begin at {rows[0][0]}, after the data starts at " + f"{start}; rows before the first offset would be corrected by a later one. Declare an offset " + f"covering the start of the data." + ) + raise ValueError(msg) return pd.DataFrame( {"timestamp": pd.DatetimeIndex([ts for ts, _ in rows]), "north_offset": [off for _, off in rows]} ) diff --git a/benchmarking/synthetic/sources/greenbyte.py b/benchmarking/synthetic/sources/greenbyte.py index 404e658d..22814f9c 100644 --- a/benchmarking/synthetic/sources/greenbyte.py +++ b/benchmarking/synthetic/sources/greenbyte.py @@ -46,6 +46,7 @@ NACELLE_POSITION = "Nacelle position (°)" WIND_SPEED = "Wind speed (m/s)" WIND_SPEED_SD = "Wind speed, Standard deviation (m/s)" +GEN_RPM = "Generator RPM (RPM)" AVAILABILITY = "availability_s" TURBINE = "TurbineName" @@ -54,7 +55,7 @@ active_power=POWER, wind_speed=WIND_SPEED, wind_speed_sd=WIND_SPEED_SD, - gen_rpm="Generator RPM (RPM)", + gen_rpm=GEN_RPM, availability=AVAILABILITY, nacelle_position=NACELLE_POSITION, ) @@ -131,7 +132,7 @@ def load_greenbyte_scada( *, years: Sequence[int], data_dir: Path | None = None, - columns: Sequence[str] = (POWER, NACELLE_POSITION, WIND_SPEED, WIND_SPEED_SD), + columns: Sequence[str] = (POWER, NACELLE_POSITION, WIND_SPEED, WIND_SPEED_SD, GEN_RPM), ) -> pd.DataFrame: """Return long, timestamp-indexed SCADA for ``farm`` over ``years``. diff --git a/tests/benchmarking/harness/test_northing.py b/tests/benchmarking/harness/test_northing.py index 01a52ab1..6788674f 100644 --- a/tests/benchmarking/harness/test_northing.py +++ b/tests/benchmarking/harness/test_northing.py @@ -143,6 +143,24 @@ def test_an_empty_list_needs_no_reanalysis(self) -> None: # would raise if this branch tried to discover north_scada(scada, columns=_COLUMNS, north_offsets=[], rated_power_kw=_RATED, era5_wd=None) + def test_a_table_starting_after_the_data_raises(self) -> None: + index = _index(days=30) + scada, _ = _scada(index, {t: [(_START, 0.0)] for t in _TURBINES}) + late = [("T01", _START + pd.Timedelta(days=5), 12.0)] + + with pytest.raises(ValueError, match="after the data starts"): + north_scada(scada, columns=_COLUMNS, north_offsets=late, rated_power_kw=_RATED, era5_wd=None) + + def test_a_turbine_with_no_declared_offset_is_left_alone(self) -> None: + index = _index(days=30) + scada, _ = _scada(index, {t: [(_START, 0.0)] for t in _TURBINES}) + only_t01 = [("T01", _START, 12.0)] + + out = north_scada(scada, columns=_COLUMNS, north_offsets=only_t01, rated_power_kw=_RATED, era5_wd=None) + + raw = scada[scada[_COLUMNS.turbine] == "T02"][_COLUMNS.nacelle_position].to_numpy(dtype=float) + assert _northed(out, "T02") == pytest.approx(raw % 360.0) + class TestStudyPath: """The study path norths every replicate, so a method sees a table wind-up worked out itself.""" diff --git a/tests/wind_up/test_northing_real_data.py b/tests/wind_up/test_northing_real_data.py index 10484a72..021314b9 100644 --- a/tests/wind_up/test_northing_real_data.py +++ b/tests/wind_up/test_northing_real_data.py @@ -33,8 +33,18 @@ FIXTURE = Path(__file__).parents[1] / "test_data" / "hot" / "northing" / "northing_inputs.parquet" ALL_TURBINES = tuple(f"T{n:02d}" for n in range(1, 22)) + +def _is_parquet(path: Path) -> bool: + """Whether ``path`` holds real Parquet rather than an unsmudged git-lfs pointer.""" + try: + with path.open("rb") as handle: + return handle.read(4) == b"PAR1" + except OSError: + return False + + pytestmark = pytest.mark.skipif( - not FIXTURE.exists(), reason="Hill of Towie northing fixture not available (git-lfs not pulled)" + not _is_parquet(FIXTURE), reason="Hill of Towie northing fixture not available (git-lfs not pulled)" ) From cbe3297826df05c4cad15bc1b2877a411dfc8f7f Mon Sep 17 00:00:00 2001 From: aclerc Date: Thu, 3 Sep 2026 17:37:48 +0100 Subject: [PATCH 12/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- tests/benchmarking/campaigns/test_placebo_end_to_end.py | 4 ++++ tests/benchmarking/diagnostics/test_diagnostics.py | 6 ++++++ tests/benchmarking/synthetic/test_wake_steering.py | 1 + tests/test_detrend.py | 1 + tests/test_northing.py | 1 + tests/test_reanalysis_data.py | 1 + tests/wind_up/test_northing_real_data.py | 4 ++++ 7 files changed, 18 insertions(+) diff --git a/tests/benchmarking/campaigns/test_placebo_end_to_end.py b/tests/benchmarking/campaigns/test_placebo_end_to_end.py index 2c7d67ca..b1b8d8a3 100644 --- a/tests/benchmarking/campaigns/test_placebo_end_to_end.py +++ b/tests/benchmarking/campaigns/test_placebo_end_to_end.py @@ -22,6 +22,10 @@ from benchmarking.campaigns.placebo import placebo_analysis_period, placebo_campaign from benchmarking.synthetic import HOT_COLUMNS, HOT_RATED_POWER_KW +# End-to-end campaign runs; the fast gate covers the pieces individually. +pytestmark = pytest.mark.slow + + if TYPE_CHECKING: from pathlib import Path diff --git a/tests/benchmarking/diagnostics/test_diagnostics.py b/tests/benchmarking/diagnostics/test_diagnostics.py index 515f70fe..79835ee0 100644 --- a/tests/benchmarking/diagnostics/test_diagnostics.py +++ b/tests/benchmarking/diagnostics/test_diagnostics.py @@ -124,17 +124,20 @@ def test_a_short_campaign_gets_more_than_one_point(self, tmp_path: Path) -> None class TestExcludedRowPlots: """The exclusion view is the *usual* 2x3 operating-curve figure, coloured kept vs excluded.""" + @pytest.mark.slow def test_written_when_rows_are_excluded(self, tmp_path: Path) -> None: ctx = _context(tmp_path, excluded=_excluded_mask()) names = {p.name for p in write_common_diagnostics(ctx)} assert "ops_curves_excluded.png" in names assert "excluded_row_fraction.png" in names + @pytest.mark.slow def test_lands_in_the_filter_stage_folder(self, tmp_path: Path) -> None: ctx = _context(tmp_path, excluded=_excluded_mask()) written = {p.name: p for p in write_common_diagnostics(ctx)} assert written["ops_curves_excluded.png"].parent.name == written["filter_coverage.png"].parent.name + @pytest.mark.slow def test_skipped_when_the_method_excludes_nothing(self, tmp_path: Path) -> None: """A clean campaign must not sprout an empty plot.""" ctx = _context(tmp_path, excluded=np.zeros(300, dtype=bool)) @@ -142,6 +145,7 @@ def test_skipped_when_the_method_excludes_nothing(self, tmp_path: Path) -> None: assert "ops_curves_excluded.png" not in names assert "excluded_row_fraction.png" not in names + @pytest.mark.slow def test_skipped_when_the_method_has_no_exclusion_concept(self, tmp_path: Path) -> None: ctx = _context(tmp_path) assert ctx.excluded_ts is None @@ -154,6 +158,7 @@ def test_a_misaligned_exclusion_mask_skips_every_diagnostic(self, tmp_path: Path assert write_common_diagnostics(ctx) == [] +@pytest.mark.slow def test_common_diagnostics_writes_expected_plots(tmp_path: Path) -> None: ctx = _context(tmp_path) written = write_common_diagnostics(ctx) @@ -213,6 +218,7 @@ def test_density_scatter_degenerate_input_does_not_raise() -> None: @pytest.mark.parametrize("with_era5", [True, False]) +@pytest.mark.slow def test_runs_without_era5(tmp_path: Path, *, with_era5: bool) -> None: ctx = _context(tmp_path, with_era5=with_era5) written = write_common_diagnostics(ctx) diff --git a/tests/benchmarking/synthetic/test_wake_steering.py b/tests/benchmarking/synthetic/test_wake_steering.py index 4078ae14..93950ad7 100644 --- a/tests/benchmarking/synthetic/test_wake_steering.py +++ b/tests/benchmarking/synthetic/test_wake_steering.py @@ -512,6 +512,7 @@ def test_end_to_end_ground_truth_naive_and_plot(tmp_path: Path) -> None: assert save_path.exists() +@pytest.mark.slow def test_wake_steering_example_driver_saves_dataset_and_plots(tmp_path: Path) -> None: """The HoT wake-steering driver builds coords from metadata and writes a dataset plus plots.""" from benchmarking.synthetic.make_example_datasets import ( # noqa: PLC0415 diff --git a/tests/test_detrend.py b/tests/test_detrend.py index 225e205e..84738b96 100644 --- a/tests/test_detrend.py +++ b/tests/test_detrend.py @@ -66,6 +66,7 @@ def test_check_applied_detrend(test_lsa_t13_config: WindUpConfig) -> None: assert detrend_post_r2_improvement == pytest.approx(0.03776561982402227) +@pytest.mark.slow def test_calc_wsratio_v_wd_scen(test_lsa_t13_config: WindUpConfig) -> None: # this test case borrows logic and results from check_applied_detrend where data which has already been detrended # is used to calculate the wsratio_v_wd_scen again to check it is flat diff --git a/tests/test_northing.py b/tests/test_northing.py index 6dd72cfe..aaad01b0 100644 --- a/tests/test_northing.py +++ b/tests/test_northing.py @@ -10,6 +10,7 @@ from wind_up_v0.scada_funcs import _scada_multi_index +@pytest.mark.slow def test_apply_northing_corrections(test_lsa_t13_config: WindUpConfig) -> None: cfg = test_lsa_t13_config test_df = pd.read_parquet(Path(__file__).parents[0] / "test_data/LSA_T13_test_df.parquet") diff --git a/tests/test_reanalysis_data.py b/tests/test_reanalysis_data.py index 0a9e4bec..15a2a8c6 100644 --- a/tests/test_reanalysis_data.py +++ b/tests/test_reanalysis_data.py @@ -20,6 +20,7 @@ def test_get_dsid_and_dates_from_filename() -> None: ) +@pytest.mark.slow def test_add_reanalysis_data(test_homer_config: WindUpConfig) -> None: cfg = test_homer_config cfg.lt_first_dt_utc_start = pd.Timestamp("2023-07-01 00:00:00", tz="UTC") diff --git a/tests/wind_up/test_northing_real_data.py b/tests/wind_up/test_northing_real_data.py index 021314b9..6b6a6acf 100644 --- a/tests/wind_up/test_northing_real_data.py +++ b/tests/wind_up/test_northing_real_data.py @@ -125,6 +125,7 @@ class TestKnownChangepoints: """All 21 turbines over two-year windows: v0's changepoints, and no others.""" @pytest.mark.parametrize(("window", "turbine"), _CASES, ids=lambda v: v if isinstance(v, str) else v[0]) + @pytest.mark.slow def test_a_turbines_known_recalibrations_are_found( self, hot: pd.DataFrame, window: tuple[str, str], turbine: str ) -> None: @@ -136,6 +137,7 @@ def test_a_turbines_known_recalibrations_are_found( assert circ_diff(step, expected_step) == pytest.approx(0.0, abs=3.0), _describe(found) @pytest.mark.parametrize(("window", "turbine"), _QUIET, ids=lambda v: v if isinstance(v, str) else v[0]) + @pytest.mark.slow def test_every_other_turbine_is_left_alone(self, hot: pd.DataFrame, window: tuple[str, str], turbine: str) -> None: found = run_farm(hot, ALL_TURBINES, *window)[turbine] assert found == [], f"{turbine}: {_describe(found)}" @@ -189,6 +191,7 @@ class TestEdgeArtefacts: """ @pytest.mark.parametrize("end", ["2019-01-01", "2019-01-03", "2019-02-01"]) + @pytest.mark.slow def test_t13_is_clean_wherever_the_record_stops(self, hot: pd.DataFrame, end: str) -> None: found = run_farm(hot, ALL_TURBINES, "2017-01-01", end)["T13"] assert found == [], _describe(found) @@ -201,6 +204,7 @@ class TestSubsetConsistency: EAST = tuple(f"T{n:02d}" for n in range(16, 22)) @pytest.mark.parametrize("half", ["west", "east"]) + @pytest.mark.slow def test_half_the_farm_agrees_with_the_whole(self, hot: pd.DataFrame, half: str) -> None: turbines = self.WEST if half == "west" else self.EAST tables = run_farm(hot, turbines, *EARLY) From 1fb32ddc3901e4dfc5dc7ba7d1aadb8395691c0b Mon Sep 17 00:00:00 2001 From: aclerc Date: Thu, 3 Sep 2026 18:08:08 +0100 Subject: [PATCH 13/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- .../campaigns/northing_natural_probe.py | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 benchmarking/campaigns/northing_natural_probe.py diff --git a/benchmarking/campaigns/northing_natural_probe.py b/benchmarking/campaigns/northing_natural_probe.py new file mode 100644 index 00000000..60b94112 --- /dev/null +++ b/benchmarking/campaigns/northing_natural_probe.py @@ -0,0 +1,204 @@ +"""Size the naturally occurring northing failure mode, with no fault injected. + +T06's nearest neighbour T05 carries real step changes in its reported yaw direction during +2017-2018, so using T05 as a reference exercises the failure mode R1 addresses without +injecting anything. The probe runs the same campaign twice -- northing off, then on -- and +reports how far the uplift estimate moves. + +The AeroUp uplift is injected so truth is non-zero and the reported number is an error rather +than placebo drift; the fixture uses the same shape, so the two are comparable. + +Run from the repo root:: + + uv run python -m benchmarking.campaigns.northing_natural_probe + +Outputs land under ``WIND_UP_BENCHMARKING_OUTPUT_DIR``/``northing_natural_probe``/``/``. +""" + +from __future__ import annotations + +import logging +import os +from functools import partial +from pathlib import Path +from typing import TYPE_CHECKING + +import matplotlib as mpl + +mpl.use("Agg") + +import pandas as pd + +from benchmarking.baselines.hot_context import build_hot_v0_context +from benchmarking.campaigns.declaration import SyntheticCampaign +from benchmarking.campaigns.methods import carried_forward_methods +from benchmarking.campaigns.northing_fixture import BASELINE_MONTHS, CAMPAIGN_START, UPLIFT +from benchmarking.campaigns.runner import CampaignRunner +from benchmarking.harness.northing import era5_direction +from benchmarking.synthetic import HOT_RATED_POWER_KW +from benchmarking.synthetic.sources.hill_of_towie import load_hot_metadata, load_hot_scada + +if TYPE_CHECKING: + from collections.abc import Sequence + + from benchmarking.campaigns.declaration import CampaignSpec + from benchmarking.harness import Method + +logger = logging.getLogger(__name__) + +PROBE_TEST_WTG = "T06" +# T05 is T06's nearest neighbour and carries real northing steps in 2017-2018. Alone it drives the +# reference direction entirely; alongside the fixture's stable references its effect is diluted, +# which is the contrast the probe reports. +REFERENCE_SETS: dict[str, tuple[str, ...]] = { + "t05_only": ("T05",), + "t05_plus_stable": ("T05", "T15", "T10", "T08"), +} +CAMPAIGN_MONTHS = 12 + + +def default_output_root() -> Path: + """Where probe runs are written.""" + root = Path(os.getenv("WIND_UP_BENCHMARKING_OUTPUT_DIR", Path.home() / "temp" / "wind-up-benchmarking")) + return root / "northing_natural_probe" + + +def analysis_period() -> tuple[pd.Timestamp, pd.Timestamp]: + """Return the whole record the methods see: the baseline year plus the campaign year.""" + return ( + CAMPAIGN_START - pd.DateOffset(months=BASELINE_MONTHS), + CAMPAIGN_START + pd.DateOffset(months=CAMPAIGN_MONTHS), + ) + + +def _coords(turbines: Sequence[str]) -> dict[str, tuple[float, float]]: + metadata = load_hot_metadata() + return { + str(row.Name): (float(row.Latitude), float(row.Longitude)) + for row in metadata.itertuples() + if str(row.Name) in set(turbines) + } + + +def probe_campaign(*, references: Sequence[str], northing: bool) -> SyntheticCampaign: + """Declare one arm: the same real campaign, with the shared northing step on or off.""" + turbines = (PROBE_TEST_WTG, *references) + return SyntheticCampaign( + upgraded_turbines=[PROBE_TEST_WTG], + upgrade_timing=CAMPAIGN_START, + candidate_references=list(references), + upgrades=list(UPLIFT), + faults=[], + coords=_coords(turbines), + north_offsets=None if northing else [], + rated_power_kw=HOT_RATED_POWER_KW, + analysis_period=analysis_period(), + ) + + +def _methods_for( + wtg: str, + *, + spec: CampaignSpec, + out_dir: Path, + era5_hourly_df: pd.DataFrame | None, + include_power_model: bool, +) -> list[Method]: + """Build one turbine's methods into its own subfolder.""" + return carried_forward_methods( + spec, + out_dir=out_dir / wtg, + era5_hourly_df=era5_hourly_df, + include_power_model=include_power_model, + ) + + +def run_probe(*, out_root: str | Path | None = None, include_power_model: bool = True) -> pd.DataFrame: + """Run every reference set with northing off and on; return one row per (set, arm, method).""" + root = Path(out_root) if out_root is not None else default_output_root() + run_dir = root / f"{pd.Timestamp.now():%Y%m%d_%H%M%S}" + run_dir.mkdir(parents=True, exist_ok=True) + + period = analysis_period() + every_turbine = sorted({PROBE_TEST_WTG, *(t for refs in REFERENCE_SETS.values() for t in refs)}) + era5_df = build_hot_v0_context(wtg_names=every_turbine).reanalysis_datasets[0].data + logger.info("loading Hill of Towie SCADA %s..%s for %s", *period, every_turbine) + scada_df, _ = load_hot_scada( + start_dt=period[0], + end_dt_excl=period[1], + wtg_numbers=[int(w[1:]) for w in every_turbine], + wtg_names=every_turbine, + ) + + rows: list[dict[str, object]] = [] + for set_name, references in REFERENCE_SETS.items(): + for northing in (False, True): + arm = "northed" if northing else "raw" + logger.info("running %s / %s", set_name, arm) + campaign = probe_campaign(references=references, northing=northing) + dataset = campaign.generate(scada_df) + spec = campaign.spec() + index = pd.DatetimeIndex(dataset.synthetic_df.index.unique()).sort_values() + runner = CampaignRunner( + spec, + dataset, + build_methods=partial( + _methods_for, + spec=spec, + out_dir=run_dir / f"{set_name}_{arm}", + era5_hourly_df=era5_df if include_power_model else None, + include_power_model=include_power_model, + ), + era5_wd=era5_direction(era5_df, index), + ) + result = runner.run() + rows.extend( + { + "reference_set": set_name, + "references": ",".join(references), + "northing": northing, + "arm": arm, + "method": row.method, + "estimate": row.estimate, + "truth": row.truth, + "signed_error": row.signed_error, + } + for row in result.farm.itertuples() + ) + + table = pd.DataFrame(rows) + table.to_csv(run_dir / "natural_probe.csv", index=False) + sensitivity = sensitivity_table(table) + sensitivity.to_csv(run_dir / "sensitivity.csv", index=False) + logger.info("wrote the probe results to %s\n%s", run_dir, sensitivity.to_string(index=False)) + return table + + +def sensitivity_table(table: pd.DataFrame) -> pd.DataFrame: + """How far northing moves the answer, per reference set and method, in percentage points.""" + rows = [] + for (set_name, method), group in table.groupby(["reference_set", "method"]): + cell = {bool(r.northing): float(r.signed_error) * 100 for r in group.itertuples()} + if len(cell) != 2: # noqa: PLR2004 - both arms are needed for a shift + continue + rows.append( + { + "reference_set": set_name, + "method": method, + "raw_error_pp": cell[False], + "northed_error_pp": cell[True], + "shift_pp": cell[True] - cell[False], + "improved_pp": abs(cell[False]) - abs(cell[True]), + } + ) + return pd.DataFrame(rows) + + +def main() -> None: + """Run the natural probe over both reference sets.""" + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + run_probe() + + +if __name__ == "__main__": + main() From 7e1c77acc2017fbf7eb5c91af5341e67e4b9ec72 Mon Sep 17 00:00:00 2001 From: aclerc Date: Thu, 3 Sep 2026 18:09:52 +0100 Subject: [PATCH 14/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- benchmarking/campaigns/northing_natural_probe.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/benchmarking/campaigns/northing_natural_probe.py b/benchmarking/campaigns/northing_natural_probe.py index 60b94112..1f3fb359 100644 --- a/benchmarking/campaigns/northing_natural_probe.py +++ b/benchmarking/campaigns/northing_natural_probe.py @@ -47,11 +47,12 @@ logger = logging.getLogger(__name__) PROBE_TEST_WTG = "T06" -# T05 is T06's nearest neighbour and carries real northing steps in 2017-2018. Alone it drives the -# reference direction entirely; alongside the fixture's stable references its effect is diluted, -# which is the contrast the probe reports. +# T05 is T06's nearest neighbour and carries real northing steps in 2017-2018. How much it moves +# the answer depends on how far it is diluted by stable references, which is the contrast the probe +# reports. T05 alone is not available: the farm consensus needs at least three devices, and the +# test turbine plus one reference is two. REFERENCE_SETS: dict[str, tuple[str, ...]] = { - "t05_only": ("T05",), + "t05_and_t15": ("T05", "T15"), "t05_plus_stable": ("T05", "T15", "T10", "T08"), } CAMPAIGN_MONTHS = 12 From 323d0a9301c07263fb0b4c7c7c0f58f14afb072a Mon Sep 17 00:00:00 2001 From: aclerc Date: Thu, 3 Sep 2026 19:13:43 +0100 Subject: [PATCH 15/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- ...toggle_methods_compare_baseline_linux.json | 724 +++++++++--------- 1 file changed, 362 insertions(+), 362 deletions(-) diff --git a/benchmarking/baselines/study_toggle_methods_compare_baseline_linux.json b/benchmarking/baselines/study_toggle_methods_compare_baseline_linux.json index 4efbd340..7df30d85 100644 --- a/benchmarking/baselines/study_toggle_methods_compare_baseline_linux.json +++ b/benchmarking/baselines/study_toggle_methods_compare_baseline_linux.json @@ -1,7 +1,7 @@ { "schema": "toggle_methods_compare_baseline_v3", - "recorded_utc": "2026-09-02T09:27:05Z", - "git_commit": "2e13ac3", + "recorded_utc": "2026-09-03T18:13:12Z", + "git_commit": "7e1c77a", "platform": "linux", "cpu_count": 12, "python_version": "3.13.14", @@ -29,14 +29,14 @@ "campaign_weeks": 1, "condition": "overall", "condition_bin": "overall", - "bias": 0.00212053, - "spread": 0.00496816, - "score": 0.00540178, - "mean_estimate": 0.00212053, + "bias": 0.00153092, + "spread": 0.00325672, + "score": 0.0035986, + "mean_estimate": 0.00153092, "mean_truth": 0.0, "n_replicates": 4, - "wall_time_s_sum": 41.54002327, - "wall_time_s_mean": 10.38500582 + "wall_time_s_sum": 91.13505007, + "wall_time_s_mean": 22.78376252 }, { "method": "power_model", @@ -44,10 +44,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.03578048, - "spread": 0.03243959, - "score": 0.04829668, - "mean_estimate": 0.03578048, + "bias": 0.0169837, + "spread": 0.05519967, + "score": 0.05775335, + "mean_estimate": 0.0169837, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -59,10 +59,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.0037551, - "spread": 0.00516287, - "score": 0.00638404, - "mean_estimate": -0.0037551, + "bias": -0.00252523, + "spread": 0.00649978, + "score": 0.00697309, + "mean_estimate": -0.00252523, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -74,10 +74,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": -0.00062454, - "spread": 0.00094686, - "score": 0.00113428, - "mean_estimate": -0.00062454, + "bias": -0.00045603, + "spread": 0.00066924, + "score": 0.00080984, + "mean_estimate": -0.00045603, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -89,10 +89,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00138423, - "spread": 0.00355077, - "score": 0.00381105, - "mean_estimate": -0.00069211, + "bias": -0.00162696, + "spread": 0.00234717, + "score": 0.0028559, + "mean_estimate": -0.00081348, "mean_truth": 0.0, "n_replicates": 2, "wall_time_s_sum": NaN, @@ -104,10 +104,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00442784, - "spread": 0.00310821, - "score": 0.00540988, - "mean_estimate": 0.00442784, + "bias": 0.00205164, + "spread": 0.00921664, + "score": 0.00944223, + "mean_estimate": 0.00205164, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -119,10 +119,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00326358, - "spread": 0.00640723, - "score": 0.00719052, - "mean_estimate": -0.00326358, + "bias": -0.00087813, + "spread": 0.00209713, + "score": 0.00227355, + "mean_estimate": -0.00087813, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -134,14 +134,14 @@ "campaign_weeks": 2, "condition": "overall", "condition_bin": "overall", - "bias": -0.00145515, - "spread": 0.00100442, - "score": 0.00176814, - "mean_estimate": -0.00145515, + "bias": -0.00197676, + "spread": 0.00064863, + "score": 0.00208046, + "mean_estimate": -0.00197676, "mean_truth": 0.0, "n_replicates": 4, - "wall_time_s_sum": 42.45689474, - "wall_time_s_mean": 10.61422369 + "wall_time_s_sum": 93.94672545, + "wall_time_s_mean": 23.48668136 }, { "method": "power_model", @@ -149,10 +149,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.04336628, - "spread": 0.06328035, - "score": 0.076714, - "mean_estimate": 0.04336628, + "bias": 0.0604819, + "spread": 0.07754474, + "score": 0.09834249, + "mean_estimate": 0.0604819, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -164,10 +164,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00021744, - "spread": 0.01509164, - "score": 0.01509321, - "mean_estimate": 0.00021744, + "bias": -0.00022189, + "spread": 0.01738477, + "score": 0.01738618, + "mean_estimate": -0.00022189, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -179,10 +179,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": -0.00588372, - "spread": 0.00785203, - "score": 0.00981185, - "mean_estimate": -0.00588372, + "bias": -0.00525892, + "spread": 0.00649993, + "score": 0.00836094, + "mean_estimate": -0.00525892, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -194,10 +194,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00167391, - "spread": 0.00168706, - "score": 0.00237658, - "mean_estimate": -0.00167391, + "bias": -0.0017179, + "spread": 0.00202135, + "score": 0.00265274, + "mean_estimate": -0.0017179, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -209,10 +209,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00100633, - "spread": 0.01438036, - "score": 0.01441553, - "mean_estimate": -0.00100633, + "bias": -0.00538757, + "spread": 0.01417692, + "score": 0.01516611, + "mean_estimate": -0.00538757, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -224,10 +224,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.0030033, - "spread": 0.0062871, - "score": 0.0069676, - "mean_estimate": -0.0030033, + "bias": -0.00471252, + "spread": 0.00721595, + "score": 0.00861845, + "mean_estimate": -0.00471252, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -239,14 +239,14 @@ "campaign_weeks": 4, "condition": "overall", "condition_bin": "overall", - "bias": -0.00274513, - "spread": 0.002166, - "score": 0.00349675, - "mean_estimate": -0.00274513, + "bias": -0.00319736, + "spread": 0.00302091, + "score": 0.00439875, + "mean_estimate": -0.00319736, "mean_truth": 0.0, "n_replicates": 4, - "wall_time_s_sum": 49.98422504, - "wall_time_s_mean": 12.49605626 + "wall_time_s_sum": 111.74586482, + "wall_time_s_mean": 27.93646621 }, { "method": "power_model", @@ -254,10 +254,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.02545278, - "spread": 0.03573182, - "score": 0.04387034, - "mean_estimate": 0.02545278, + "bias": 0.02989959, + "spread": 0.04111247, + "score": 0.05083523, + "mean_estimate": 0.02989959, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -269,10 +269,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00168465, - "spread": 0.0131588, - "score": 0.0132662, - "mean_estimate": 0.00168465, + "bias": 0.00041338, + "spread": 0.00725199, + "score": 0.00726376, + "mean_estimate": 0.00041338, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -284,10 +284,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": -0.00099339, - "spread": 0.01288547, - "score": 0.01292371, - "mean_estimate": -0.00099339, + "bias": -0.00426448, + "spread": 0.01194906, + "score": 0.01268723, + "mean_estimate": -0.00426448, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -299,10 +299,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00011012, - "spread": 0.00431178, - "score": 0.00431318, - "mean_estimate": -0.00011012, + "bias": -0.00029023, + "spread": 0.00397856, + "score": 0.00398913, + "mean_estimate": -0.00029023, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -314,10 +314,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00234876, - "spread": 0.01363567, - "score": 0.01383648, - "mean_estimate": -0.00234876, + "bias": -0.00459725, + "spread": 0.01407317, + "score": 0.01480503, + "mean_estimate": -0.00459725, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -329,10 +329,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00901116, - "spread": 0.00783727, - "score": 0.01194252, - "mean_estimate": -0.00901116, + "bias": -0.00727396, + "spread": 0.00716255, + "score": 0.01020846, + "mean_estimate": -0.00727396, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -344,14 +344,14 @@ "campaign_weeks": 8, "condition": "overall", "condition_bin": "overall", - "bias": -0.00065405, - "spread": 0.00173836, - "score": 0.00185733, - "mean_estimate": -0.00065405, + "bias": -0.00074627, + "spread": 0.00241847, + "score": 0.00253099, + "mean_estimate": -0.00074627, "mean_truth": 0.0, "n_replicates": 4, - "wall_time_s_sum": 62.6152422, - "wall_time_s_mean": 15.65381055 + "wall_time_s_sum": 144.43399071, + "wall_time_s_mean": 36.10849768 }, { "method": "power_model", @@ -359,10 +359,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.0121854, - "spread": 0.0203579, - "score": 0.02372611, - "mean_estimate": 0.0121854, + "bias": 0.01098982, + "spread": 0.01617535, + "score": 0.01955551, + "mean_estimate": 0.01098982, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -374,10 +374,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00151873, - "spread": 0.00796679, - "score": 0.00811026, - "mean_estimate": -0.00151873, + "bias": 0.00154715, + "spread": 0.00752118, + "score": 0.00767866, + "mean_estimate": 0.00154715, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -389,10 +389,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00161253, - "spread": 0.00346862, - "score": 0.00382513, - "mean_estimate": 0.00161253, + "bias": 0.00130928, + "spread": 0.00424955, + "score": 0.00444668, + "mean_estimate": 0.00130928, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -404,10 +404,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00394966, - "spread": 0.00356512, - "score": 0.00532071, - "mean_estimate": -0.00394966, + "bias": -0.0028017, + "spread": 0.00246559, + "score": 0.00373211, + "mean_estimate": -0.0028017, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -419,10 +419,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00278577, - "spread": 0.00300628, - "score": 0.00409857, - "mean_estimate": 0.00278577, + "bias": -0.00082858, + "spread": 0.00469163, + "score": 0.00476423, + "mean_estimate": -0.00082858, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -434,10 +434,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00240403, - "spread": 0.00188759, - "score": 0.00305652, - "mean_estimate": -0.00240403, + "bias": -0.00317479, + "spread": 0.00313141, + "score": 0.00445926, + "mean_estimate": -0.00317479, "mean_truth": 0.0, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -449,14 +449,14 @@ "campaign_weeks": 1, "condition": "overall", "condition_bin": "overall", - "bias": 0.00216859, - "spread": 0.00488815, - "score": 0.0053476, - "mean_estimate": -0.01417373, + "bias": 0.00088478, + "spread": 0.00247641, + "score": 0.00262972, + "mean_estimate": -0.01545753, "mean_truth": -0.01634232, "n_replicates": 4, - "wall_time_s_sum": 52.91670599, - "wall_time_s_mean": 13.2291765 + "wall_time_s_sum": 97.78203885, + "wall_time_s_mean": 24.44550971 }, { "method": "power_model", @@ -464,10 +464,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00334892, - "spread": 0.05089284, - "score": 0.05100291, - "mean_estimate": -0.03019286, + "bias": -0.00148628, + "spread": 0.05571938, + "score": 0.0557392, + "mean_estimate": -0.03502806, "mean_truth": -0.03354178, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -479,10 +479,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00551049, - "spread": 0.01487343, - "score": 0.01586142, - "mean_estimate": -0.01406399, + "bias": 0.00759949, + "spread": 0.01407852, + "score": 0.01599865, + "mean_estimate": -0.01197499, "mean_truth": -0.01957448, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -494,10 +494,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.01031057, - "spread": 0.00606346, - "score": 0.01196133, - "mean_estimate": -0.00314399, + "bias": 0.0100873, + "spread": 0.00662706, + "score": 0.01206945, + "mean_estimate": -0.00336726, "mean_truth": -0.01345456, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -509,10 +509,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00309363, - "spread": 0.00372064, - "score": 0.00483877, - "mean_estimate": -0.00178951, + "bias": -0.00232205, + "spread": 0.00226167, + "score": 0.00324146, + "mean_estimate": -0.00140371, "mean_truth": -0.00048538, "n_replicates": 2, "wall_time_s_sum": NaN, @@ -524,10 +524,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00019284, - "spread": 0.01836956, - "score": 0.01837057, - "mean_estimate": -0.01980657, + "bias": -0.00269986, + "spread": 0.02386981, + "score": 0.02402201, + "mean_estimate": -0.02269927, "mean_truth": -0.01999941, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -539,10 +539,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00722401, - "spread": 0.01353784, - "score": 0.01534468, - "mean_estimate": -0.0127592, + "bias": 0.00872087, + "spread": 0.01127891, + "score": 0.01425719, + "mean_estimate": -0.01126234, "mean_truth": -0.01998321, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -554,14 +554,14 @@ "campaign_weeks": 2, "condition": "overall", "condition_bin": "overall", - "bias": -0.00141171, - "spread": 0.00097946, - "score": 0.00171822, - "mean_estimate": -0.01607644, + "bias": -0.00166106, + "spread": 0.0009105, + "score": 0.00189424, + "mean_estimate": -0.01632579, "mean_truth": -0.01466473, "n_replicates": 4, - "wall_time_s_sum": 53.75505569, - "wall_time_s_mean": 13.43876392 + "wall_time_s_sum": 100.6431882, + "wall_time_s_mean": 25.16079705 }, { "method": "power_model", @@ -569,10 +569,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.04077033, - "spread": 0.06484107, - "score": 0.07659363, - "mean_estimate": 0.01860648, + "bias": 0.05564345, + "spread": 0.07420867, + "score": 0.092753, + "mean_estimate": 0.0334796, "mean_truth": -0.02216385, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -584,10 +584,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00108161, - "spread": 0.0155629, - "score": 0.01560044, - "mean_estimate": -0.02060465, + "bias": -0.0010293, + "spread": 0.01611461, + "score": 0.01614745, + "mean_estimate": -0.02055233, "mean_truth": -0.01952304, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -599,10 +599,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00088153, - "spread": 0.01162113, - "score": 0.01165452, - "mean_estimate": -0.01096101, + "bias": 0.00090258, + "spread": 0.01118415, + "score": 0.01122051, + "mean_estimate": -0.01093996, "mean_truth": -0.01184254, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -614,10 +614,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00142681, - "spread": 0.00206163, - "score": 0.00250721, - "mean_estimate": -0.00197142, + "bias": -0.00150125, + "spread": 0.00260022, + "score": 0.00300248, + "mean_estimate": -0.00204586, "mean_truth": -0.00054461, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -629,10 +629,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00218414, - "spread": 0.01304534, - "score": 0.01322692, - "mean_estimate": -0.02218357, + "bias": -0.00683626, + "spread": 0.01474244, + "score": 0.01625035, + "mean_estimate": -0.02683568, "mean_truth": -0.01999942, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -644,10 +644,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00393937, - "spread": 0.00540351, - "score": 0.00668704, - "mean_estimate": -0.02392199, + "bias": -0.00461887, + "spread": 0.00714568, + "score": 0.00850851, + "mean_estimate": -0.02460149, "mean_truth": -0.01998261, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -659,14 +659,14 @@ "campaign_weeks": 4, "condition": "overall", "condition_bin": "overall", - "bias": -0.00269205, - "spread": 0.00212661, - "score": 0.00343069, - "mean_estimate": -0.01706086, + "bias": -0.00333628, + "spread": 0.00324602, + "score": 0.00465482, + "mean_estimate": -0.01770509, "mean_truth": -0.01436881, "n_replicates": 4, - "wall_time_s_sum": 59.29139102, - "wall_time_s_mean": 14.82284776 + "wall_time_s_sum": 127.17796884, + "wall_time_s_mean": 31.79449221 }, { "method": "power_model", @@ -674,10 +674,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.02593642, - "spread": 0.03372371, - "score": 0.04254393, - "mean_estimate": 0.00444518, + "bias": 0.02731794, + "spread": 0.03757569, + "score": 0.04645646, + "mean_estimate": 0.0058267, "mean_truth": -0.02149123, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -689,10 +689,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00325425, - "spread": 0.01293019, - "score": 0.01333342, - "mean_estimate": -0.0162609, + "bias": 0.0026251, + "spread": 0.00750953, + "score": 0.00795513, + "mean_estimate": -0.01689005, "mean_truth": -0.01951516, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -704,10 +704,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00057381, - "spread": 0.01323005, - "score": 0.01324249, - "mean_estimate": -0.01063965, + "bias": -0.00497688, + "spread": 0.01034359, + "score": 0.01147864, + "mean_estimate": -0.01619034, "mean_truth": -0.01121346, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -719,10 +719,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00057491, - "spread": 0.00427234, - "score": 0.00431085, - "mean_estimate": -0.00119313, + "bias": -5.502e-05, + "spread": 0.00477339, + "score": 0.00477371, + "mean_estimate": -0.00067323, "mean_truth": -0.00061822, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -734,10 +734,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00304988, - "spread": 0.01214225, - "score": 0.01251942, - "mean_estimate": -0.02304932, + "bias": -0.00487665, + "spread": 0.01280409, + "score": 0.01370133, + "mean_estimate": -0.0248761, "mean_truth": -0.01999945, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -749,10 +749,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00940856, - "spread": 0.00780185, - "score": 0.01222251, - "mean_estimate": -0.02939163, + "bias": -0.00851134, + "spread": 0.00732249, + "score": 0.01122772, + "mean_estimate": -0.02849441, "mean_truth": -0.01998307, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -764,14 +764,14 @@ "campaign_weeks": 8, "condition": "overall", "condition_bin": "overall", - "bias": -0.00063105, - "spread": 0.00171127, - "score": 0.00182392, - "mean_estimate": -0.01475207, + "bias": -0.00075822, + "spread": 0.00222575, + "score": 0.00235135, + "mean_estimate": -0.01487925, "mean_truth": -0.01412102, "n_replicates": 4, - "wall_time_s_sum": 69.8382834, - "wall_time_s_mean": 17.45957085 + "wall_time_s_sum": 146.99818389, + "wall_time_s_mean": 36.74954597 }, { "method": "power_model", @@ -779,10 +779,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.01047913, - "spread": 0.01860025, - "score": 0.02134904, - "mean_estimate": -0.01081397, + "bias": 0.01079516, + "spread": 0.01490827, + "score": 0.0184063, + "mean_estimate": -0.01049795, "mean_truth": -0.02129311, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -794,10 +794,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00057428, - "spread": 0.00639227, - "score": 0.00641802, - "mean_estimate": -0.01894376, + "bias": 0.00202192, + "spread": 0.00678147, + "score": 0.00707647, + "mean_estimate": -0.01749613, "mean_truth": -0.01951804, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -809,10 +809,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00257836, - "spread": 0.00255889, - "score": 0.00363261, - "mean_estimate": -0.00864315, + "bias": 0.00210864, + "spread": 0.00495014, + "score": 0.00538054, + "mean_estimate": -0.00911286, "mean_truth": -0.0112215, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -824,10 +824,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00437483, - "spread": 0.00415485, - "score": 0.0060334, - "mean_estimate": -0.0049394, + "bias": -0.00335359, + "spread": 0.00235322, + "score": 0.00409685, + "mean_estimate": -0.00391815, "mean_truth": -0.00056457, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -839,10 +839,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00342832, - "spread": 0.00344346, - "score": 0.00485909, - "mean_estimate": -0.01657115, + "bias": -0.00021965, + "spread": 0.00509995, + "score": 0.00510468, + "mean_estimate": -0.02021912, "mean_truth": -0.01999947, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -854,10 +854,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00370887, - "spread": 0.00138652, - "score": 0.00395957, - "mean_estimate": -0.02369239, + "bias": -0.00357108, + "spread": 0.00278939, + "score": 0.00453137, + "mean_estimate": -0.02355461, "mean_truth": -0.01998352, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -869,14 +869,14 @@ "campaign_weeks": 1, "condition": "overall", "condition_bin": "overall", - "bias": 0.00204731, - "spread": 0.00493686, - "score": 0.00534454, - "mean_estimate": 0.01838963, + "bias": 0.00206062, + "spread": 0.00378282, + "score": 0.00430765, + "mean_estimate": 0.01840293, "mean_truth": 0.01634232, "n_replicates": 4, - "wall_time_s_sum": 45.64104997, - "wall_time_s_mean": 11.41026249 + "wall_time_s_sum": 96.14693125, + "wall_time_s_mean": 24.03673281 }, { "method": "power_model", @@ -884,10 +884,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.02959148, - "spread": 0.03137062, - "score": 0.04312507, - "mean_estimate": 0.06313326, + "bias": 0.02096236, + "spread": 0.06046319, + "score": 0.06399389, + "mean_estimate": 0.05450414, "mean_truth": 0.03354178, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -899,10 +899,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.01371332, - "spread": 0.00636871, - "score": 0.01512004, - "mean_estimate": 0.00586116, + "bias": -0.01317777, + "spread": 0.00820625, + "score": 0.01552405, + "mean_estimate": 0.0063967, "mean_truth": 0.01957448, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -914,10 +914,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": -0.01137595, - "spread": 0.00503684, - "score": 0.01244114, - "mean_estimate": 0.00207861, + "bias": -0.01085881, + "spread": 0.00584694, + "score": 0.0123329, + "mean_estimate": 0.00259575, "mean_truth": 0.01345456, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -929,10 +929,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00080482, - "spread": 0.00342122, - "score": 0.00351461, - "mean_estimate": -0.00015972, + "bias": -0.00122648, + "spread": 0.00171266, + "score": 0.00210653, + "mean_estimate": -0.00037055, "mean_truth": 0.00048538, "n_replicates": 2, "wall_time_s_sum": NaN, @@ -944,10 +944,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.0272687, - "spread": 0.02007337, - "score": 0.03386034, - "mean_estimate": 0.04726811, + "bias": 0.0259223, + "spread": 0.02278362, + "score": 0.03451173, + "mean_estimate": 0.04592171, "mean_truth": 0.01999941, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -959,10 +959,10 @@ "campaign_weeks": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.01182942, - "spread": 0.0093045, - "score": 0.01505022, - "mean_estimate": 0.00815379, + "bias": -0.00975418, + "spread": 0.01028472, + "score": 0.01417461, + "mean_estimate": 0.01022903, "mean_truth": 0.01998321, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -974,14 +974,14 @@ "campaign_weeks": 2, "condition": "overall", "condition_bin": "overall", - "bias": -0.00150166, - "spread": 0.00103005, - "score": 0.00182099, - "mean_estimate": 0.01316306, + "bias": -0.00175897, + "spread": 0.00095377, + "score": 0.00200091, + "mean_estimate": 0.01290576, "mean_truth": 0.01466473, "n_replicates": 4, - "wall_time_s_sum": 47.8756218, - "wall_time_s_mean": 11.96890545 + "wall_time_s_sum": 89.90021109, + "wall_time_s_mean": 22.47505277 }, { "method": "power_model", @@ -989,10 +989,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.04369483, - "spread": 0.06477994, - "score": 0.07813885, - "mean_estimate": 0.06585868, + "bias": 0.06365189, + "spread": 0.07447403, + "score": 0.0979691, + "mean_estimate": 0.08581574, "mean_truth": 0.02216385, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1004,10 +1004,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00197452, - "spread": 0.01718092, - "score": 0.01729401, - "mean_estimate": 0.02149756, + "bias": -0.00066205, + "spread": 0.01509447, + "score": 0.01510899, + "mean_estimate": 0.01886099, "mean_truth": 0.01952304, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1019,10 +1019,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": -0.01204737, - "spread": 0.00500634, - "score": 0.01304617, - "mean_estimate": -0.00020483, + "bias": -0.01240069, + "spread": 0.00494022, + "score": 0.01334852, + "mean_estimate": -0.00055815, "mean_truth": 0.01184254, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1034,10 +1034,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00228108, - "spread": 0.00187543, - "score": 0.00295306, - "mean_estimate": -0.00173647, + "bias": -0.00202515, + "spread": 0.00179183, + "score": 0.00270405, + "mean_estimate": -0.00148055, "mean_truth": 0.00054461, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1049,10 +1049,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00072225, - "spread": 0.01320974, - "score": 0.01322947, - "mean_estimate": 0.02072168, + "bias": -0.00420755, + "spread": 0.01458106, + "score": 0.01517599, + "mean_estimate": 0.01579188, "mean_truth": 0.01999942, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1064,10 +1064,10 @@ "campaign_weeks": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00276819, - "spread": 0.00986853, - "score": 0.01024942, - "mean_estimate": 0.01721443, + "bias": -4.81e-06, + "spread": 0.00546053, + "score": 0.00546053, + "mean_estimate": 0.0199778, "mean_truth": 0.01998261, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1079,14 +1079,14 @@ "campaign_weeks": 4, "condition": "overall", "condition_bin": "overall", - "bias": -0.00279455, - "spread": 0.00220683, - "score": 0.00356085, - "mean_estimate": 0.01157426, + "bias": -0.00342339, + "spread": 0.00336121, + "score": 0.00479764, + "mean_estimate": 0.01094542, "mean_truth": 0.01436881, "n_replicates": 4, - "wall_time_s_sum": 55.23556344, - "wall_time_s_mean": 13.80889086 + "wall_time_s_sum": 98.66737375, + "wall_time_s_mean": 24.66684344 }, { "method": "power_model", @@ -1094,10 +1094,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.02671522, - "spread": 0.03589647, - "score": 0.04474661, - "mean_estimate": 0.04820645, + "bias": 0.02802043, + "spread": 0.0441845, + "score": 0.0523203, + "mean_estimate": 0.04951166, "mean_truth": 0.02149123, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1109,10 +1109,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 1.414e-05, - "spread": 0.01353942, - "score": 0.01353943, - "mean_estimate": 0.0195293, + "bias": -0.0013793, + "spread": 0.00660842, + "score": 0.00675083, + "mean_estimate": 0.01813586, "mean_truth": 0.01951516, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1124,10 +1124,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": -0.00152712, - "spread": 0.01176842, - "score": 0.01186709, - "mean_estimate": 0.00968633, + "bias": -0.00459444, + "spread": 0.01242687, + "score": 0.013249, + "mean_estimate": 0.00661902, "mean_truth": 0.01121346, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1139,10 +1139,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00019819, - "spread": 0.00461436, - "score": 0.00461861, - "mean_estimate": 0.00042003, + "bias": -0.00022839, + "spread": 0.00488431, + "score": 0.00488964, + "mean_estimate": 0.00038983, "mean_truth": 0.00061822, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1154,10 +1154,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00116882, - "spread": 0.0125763, - "score": 0.0126305, - "mean_estimate": 0.01883062, + "bias": -0.00420339, + "spread": 0.01460367, + "score": 0.01519656, + "mean_estimate": 0.01579606, "mean_truth": 0.01999945, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1169,10 +1169,10 @@ "campaign_weeks": 4, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00941875, - "spread": 0.00788879, - "score": 0.012286, - "mean_estimate": 0.01056432, + "bias": -0.00714449, + "spread": 0.00702649, + "score": 0.01002074, + "mean_estimate": 0.01283858, "mean_truth": 0.01998307, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1184,14 +1184,14 @@ "campaign_weeks": 8, "condition": "overall", "condition_bin": "overall", - "bias": -0.00067746, - "spread": 0.00176554, - "score": 0.00189105, - "mean_estimate": 0.01344356, + "bias": -0.00088297, + "spread": 0.00255765, + "score": 0.00270577, + "mean_estimate": 0.01323805, "mean_truth": 0.01412102, "n_replicates": 4, - "wall_time_s_sum": 70.77276491, - "wall_time_s_mean": 17.69319123 + "wall_time_s_sum": 126.66614503, + "wall_time_s_mean": 31.66653626 }, { "method": "power_model", @@ -1199,10 +1199,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00990575, - "spread": 0.01900175, - "score": 0.02142873, - "mean_estimate": 0.03119886, + "bias": 0.00966463, + "spread": 0.0156804, + "score": 0.01841956, + "mean_estimate": 0.03095774, "mean_truth": 0.02129311, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1214,10 +1214,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00167726, - "spread": 0.00813979, - "score": 0.0083108, - "mean_estimate": 0.01784078, + "bias": -0.00071915, + "spread": 0.00743477, + "score": 0.00746947, + "mean_estimate": 0.01879889, "mean_truth": 0.01951804, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1229,10 +1229,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00079506, - "spread": 0.00265705, - "score": 0.00277346, - "mean_estimate": 0.01201657, + "bias": -0.00052242, + "spread": 0.00409672, + "score": 0.00412989, + "mean_estimate": 0.01069908, "mean_truth": 0.0112215, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1244,10 +1244,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00383932, - "spread": 0.004025, - "score": 0.00556247, - "mean_estimate": -0.00327475, + "bias": -0.00258669, + "spread": 0.00258084, + "score": 0.00365399, + "mean_estimate": -0.00202212, "mean_truth": 0.00056457, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1259,10 +1259,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00345704, - "spread": 0.00359087, - "score": 0.00498452, - "mean_estimate": 0.02345651, + "bias": 0.00011201, + "spread": 0.00462956, + "score": 0.00463092, + "mean_estimate": 0.02011149, "mean_truth": 0.01999947, "n_replicates": 4, "wall_time_s_sum": NaN, @@ -1274,10 +1274,10 @@ "campaign_weeks": 8, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00329188, - "spread": 0.00269911, - "score": 0.00425695, - "mean_estimate": 0.01669165, + "bias": -0.00201307, + "spread": 0.00344607, + "score": 0.00399097, + "mean_estimate": 0.01797045, "mean_truth": 0.01998352, "n_replicates": 4, "wall_time_s_sum": NaN, From 7baed4c45bfb79b181c6427f57fdc4e6334fa761 Mon Sep 17 00:00:00 2001 From: aclerc Date: Thu, 3 Sep 2026 19:22:04 +0100 Subject: [PATCH 16/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- docs/v1/issues_campaigns.md | 106 +++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/docs/v1/issues_campaigns.md b/docs/v1/issues_campaigns.md index 7e71cea2..5aeb8156 100644 --- a/docs/v1/issues_campaigns.md +++ b/docs/v1/issues_campaigns.md @@ -82,6 +82,47 @@ and `docs/superpowers/specs/2026-08-28-v1-productization-release-design.md` - **Then re-verify on campaigns.** The best faults are re-injected into the relevant whole-farm campaigns (R1/R3 ↔ C3/C5) as an in-context check. +## Updating the frozen benchmarks + +Learned the hard way while landing R1, which changed a **shared** feature-engineering step and +so moved every frozen artefact at once. Read this before accepting any benchmark change. + +- **There are four frozen baselines, not one.** `study_power_model_compare_baseline.json` plus + `study_toggle_methods_compare_baseline_{linux,portable,win32}.json`. A method-internal change + usually touches one; a change to a shared step touches all of them, because every study driver + inherits it. +- **Commit before running a sweep.** `--accept-candidate` refuses a candidate recorded from a + dirty tree, and rightly so: the artefact would be stamped with a commit that cannot reproduce + it. `study_power_model_compare` captures HEAD *before* the sweep, so committing while it runs + is safe. Untracked files do not count as dirty. +- **Isolate the change before accepting it.** Diffing a fresh run against a baseline recorded + weeks ago measures every commit since, not your change. Re-run with the change disabled + (`--method-overrides '{"": false}'`, which deliberately writes no candidate) and diff the + two runs. On R1 this took under an hour and showed the intervening seven weeks of work + contributed under 0.0002 pp — so the whole movement was attributable, and a real worry was + retired rather than carried. +- **The MOVED / UNCHANGED verdict is a blunt instrument.** Split by condition and by bin before + believing it. Degenerate bins — near-zero power, TI 0.4–0.5, wind speed 0–2 m/s — dominate the + means while their medians sit at zero. On R1's toggle diff the whole "62 of 84 cells MOVED, + max 2.96 pp" verdict came from the near-zero-power bin; the twelve headline cells moved + +0.005 pp. +- **Mind the units.** `benchmark_comparison.csv` is in **fractions**; the logs print **percentage + points**. The logged "max delta" is the largest of bias/spread/score, not score alone. Compare + like for like or you will chase a factor of 100. +- **The two scripts have different accept mechanics.** `study_power_model_compare` writes a + candidate every full sweep, so `--accept-candidate` promotes it with no re-run. + `study_toggle_methods_compare` has no candidate: `--update-baseline` re-runs the whole sweep. + Budget for that. +- **A method that should not move is a free control.** `toggle_specialist` reads no direction + signal, so R1 predicted its portable baseline would not move, and it did not (max 5e-07 pp). + The toggle script rewrites the portable file *only when it actually changes*, so "portable + baseline unchanged" in the log is a real check, not boilerplate. Predict which cells must be + untouched and treat a violation as a bug in the change. +- **The `power_model` baselines are machine-specific but not load-sensitive.** LightGBM's + threaded reduction order depends on the machine, so record and diff on one box. It does *not* + depend on machine load: R1 ran sweeps concurrently with the full test suite and still + reproduced a baseline to 1e-6, so there is no need to keep the machine idle. + ## Suggested order `C0 ✅ → [W0 ✅ early] → C1 ✅ → C2 → [R1 R2 R3 R4] → C3 → C4 → C5 → C6 → C8 → W1 → W2.` @@ -92,7 +133,8 @@ independent and runs **early** (after C0) so later code lands in the new layout; **W1/W2** are **terminal** (after C6 + R4) because the composed `wind-up` method needs the robustness and campaign pieces first. **C8** (per-turbine change histories) lands **before W1** so the generalized declaration is what gets promoted to public API, not -the flat one. C7 (drop `rlearner`, ✅ done) was independent. +the flat one. C7 (drop `rlearner`, ✅ done) was independent. **R5** (northing refinement) +is deliberately outside this order: it is future work R1 identified but does not need. **Done so far:** C0, W0, C7 and C1. **Next: C2** — with C1 in hand, decide how the `CampaignSpec` reaches the methods before the demanding campaigns build on the seam. @@ -498,6 +540,68 @@ channels / gaps. --- +## R5 — Northing refinement: small-N devices, and absolute accuracy from wake nadirs + +**Status:** future work, not blocking. R1 delivered a norther good enough to ship; these are +the two places it is known to fall short, both identified while doing R1. + +**Goal:** north devices a farm consensus cannot reach, and improve the *absolute* accuracy of +the answer rather than only its internal consistency. + +### Part A — north one or two devices with pass 1 alone + +`north_farm` refuses below `min_devices_for_farm_reference=3`, so a **two-device campaign +cannot use farm-consensus northing at all** (measured during R1's natural probe). At exactly +three the quorum is every device. Small campaigns are common, so this is a real gap, not a +corner. + +Pass 1 already norths each device against reanalysis on its own, so the machinery exists; what +limits it is accuracy. `REANALYSIS_MIN_STEP_DEG = 10` exists because reanalysis carries its own +direction-dependent bias, and a spell of unusual wind moves every turbine's residual against it +together. Against a farm consensus that common-mode error cancels; against reanalysis it does +not. So a single-device answer is currently trustworthy only for gross recalibrations. + +**Why this is tractable:** the development loop is unusually good. Hill of Towie has 21 turbines +with a published, independently-derived northing table, so pass 1 can be run **one turbine at a +time** and scored directly against a known answer, 21 times over, without any synthetic data. +`tests/wind_up/test_northing_real_data.py::TestSingleTurbineAgainstReanalysis` is the seed of +this; it currently asserts only that a lone turbine finds its *large* recalibration. + +**Done when:** a single device is northed to a stated accuracy against the published HoT table +across all 21 turbines; `REANALYSIS_MIN_STEP_DEG` is lowered by evidence rather than assertion; +challenging synthetic cases (small steps, steps near the record edge, steps during an outage) +pass; and `north_farm`'s three-device floor is either removed or documented as the deliberate +boundary between two supported regimes. + +### Part B — pass 3: nudge to absolute truth using apparent wake nadirs + +Passes 1 and 2 fix the farm relative to reanalysis and then to itself. Neither has a *physical* +absolute reference. Wake nadirs do: when the wind blows along the line joining two turbines, the +downstream one sits in the upstream one's wake and its power dips, and the direction at which +that dip occurs is known from the layout geometry alone. Matching measured nadirs to geometric +bearings gives an absolute anchor that owes nothing to a reanalysis model. + +**This is a third pass, not a replacement.** It runs only once the changepoints and their +relative steps are settled, because it estimates a single absolute shift per segment; asking it +to find changepoints as well would be a different and much harder problem. Order: +reanalysis anchor, farm consensus, then wake-nadir refinement. + +**Existing machinery to build from:** the synthetic generator's `WakeSteering` upgrade already +derives directed pairs from geometry, and `inspect_wake_steering_case.py` computes a pair's +nadir and sector. What is missing is the inverse — *measuring* an apparent nadir from a real +power-deficit-versus-direction curve, and turning a set of those into one offset per segment. + +**Done when:** measured nadirs recover a known injected absolute offset on synthetic data; on +Hill of Towie the pass-3 correction is small (it should be, since pass 1/2 already agree with +the published table to ~1°) and does not disturb the changepoints; and it degrades gracefully +where geometry gives too few usable pairs. + +**Gotcha to design around:** a turbine's own wake-affected rows are exactly the rows an uplift +method wants to treat carefully, so pass 3 must not quietly change which rows downstream +analysis considers valid. It outputs an offset, nothing else. + +--- + # Productization issues (W-series) Turn the winning pieces into a shippable **v1.0.0**: one headline method named From d9072b38e9c99956a9a24dae09fa86d507d3f083 Mon Sep 17 00:00:00 2001 From: aclerc Date: Thu, 3 Sep 2026 20:13:10 +0100 Subject: [PATCH 17/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- .../study_power_model_compare_baseline.json | 10956 ++++++++-------- 1 file changed, 5478 insertions(+), 5478 deletions(-) diff --git a/benchmarking/baselines/study_power_model_compare_baseline.json b/benchmarking/baselines/study_power_model_compare_baseline.json index 4f713008..3ddf954f 100644 --- a/benchmarking/baselines/study_power_model_compare_baseline.json +++ b/benchmarking/baselines/study_power_model_compare_baseline.json @@ -2,8 +2,8 @@ "schema": "power_model_compare_baseline_v2", "modes": { "prepost": { - "recorded_utc": "2026-07-16T10:27:18Z", - "git_commit": "4f07d64", + "recorded_utc": "2026-09-03T19:12:27Z", + "git_commit": "cbe3297", "n_replicates": 4, "seed": 0, "campaign_months": [ @@ -28,63 +28,63 @@ "campaign_months": 1, "condition": "overall", "condition_bin": "overall", - "bias": -0.00426993, - "spread": 0.01114444, - "score": 0.01193444 + "bias": -0.00316592, + "spread": 0.00493007, + "score": 0.00585906 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.01403838, - "spread": 0.03027426, - "score": 0.03337076 + "bias": -0.02585832, + "spread": 0.03601235, + "score": 0.04433443 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00130826, - "spread": 0.02269091, - "score": 0.02272859 + "bias": 0.0016082, + "spread": 0.01536131, + "score": 0.01544526 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.01820391, - "spread": 0.0198524, - "score": 0.02693511 + "bias": 0.01956894, + "spread": 0.01653998, + "score": 0.02562254 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00342305, - "spread": 0.00288274, - "score": 0.00447521 + "bias": 3.34e-06, + "spread": 0.0029462, + "score": 0.0029462 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.02539078, - "spread": 0.02708964, - "score": 0.03712869 + "bias": -0.02089769, + "spread": 0.01602341, + "score": 0.02633369 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00552863, - "spread": 0.01140449, - "score": 0.01267391 + "bias": -0.00693611, + "spread": 0.012968, + "score": 0.01470642 }, { "profile": "cp_0pct", @@ -100,126 +100,126 @@ "campaign_months": 1, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.01115517, - "spread": 0.01166995, - "score": 0.0161439 + "bias": -0.00861978, + "spread": 0.01101031, + "score": 0.01398311 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00898175, - "spread": 0.01381936, - "score": 0.01648171 + "bias": -0.00867235, + "spread": 0.01067157, + "score": 0.01375107 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00527831, - "spread": 0.0089954, - "score": 0.01042966 + "bias": -0.00457118, + "spread": 0.00577772, + "score": 0.00736734 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00050982, - "spread": 0.02069985, - "score": 0.02070613 + "bias": 0.00049716, + "spread": 0.00854871, + "score": 0.00856316 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01241038, - "spread": 0.04168635, - "score": 0.04349447 + "bias": 0.01977404, + "spread": 0.02246949, + "score": 0.02993143 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.03446985, - "spread": 0.01738451, - "score": 0.03860559 + "bias": 0.03234079, + "spread": 0.03100835, + "score": 0.04480451 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.34957812, - "spread": 0.65137518, - "score": 0.73925266 + "bias": 0.31737391, + "spread": 0.66095289, + "score": 0.73320183 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.03689664, - "spread": 0.06119684, - "score": 0.07145919 + "bias": 0.00643499, + "spread": 0.01413111, + "score": 0.01552731 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.00426993, - "spread": 0.01114444, - "score": 0.01193444 + "bias": -0.00316592, + "spread": 0.00493007, + "score": 0.00585906 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.02317022, - "spread": 0.09330657, - "score": 0.09614039 + "bias": -0.00241346, + "spread": 0.10045001, + "score": 0.100479 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.0015472, - "spread": 0.01819121, - "score": 0.01825688 + "bias": 0.00219353, + "spread": 0.01341727, + "score": 0.01359539 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00710443, - "spread": 0.01864056, - "score": 0.01994852 + "bias": 0.00464133, + "spread": 0.01888346, + "score": 0.01944549 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00464541, - "spread": 0.00964761, - "score": 0.01070777 + "bias": 0.00340313, + "spread": 0.00878384, + "score": 0.00942004 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00674772, - "spread": 0.0116874, - "score": 0.01349544 + "bias": 0.00461722, + "spread": 0.00799726, + "score": 0.00923444 }, { "profile": "cp_0pct", @@ -235,9 +235,9 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.13629254, - "spread": 0.14721031, - "score": 0.20061539 + "bias": -0.13605129, + "spread": 0.14082153, + "score": 0.19580771 }, { "profile": "cp_0pct", @@ -271,225 +271,225 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00543029, - "spread": 0.0128047, - "score": 0.01390857 + "bias": -0.00804668, + "spread": 0.01930565, + "score": 0.02091547 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00298802, - "spread": 0.01310704, - "score": 0.01344332 + "bias": -0.00195226, + "spread": 0.01366833, + "score": 0.01380704 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.01380014, - "spread": 0.01770091, - "score": 0.02244473 + "bias": -0.01085237, + "spread": 0.01053626, + "score": 0.0151257 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "overall", "condition_bin": "overall", - "bias": -7.415e-05, - "spread": 0.00194037, - "score": 0.00194179 + "bias": 0.0037626, + "spread": 0.00460693, + "score": 0.0059482 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.01487373, - "spread": 0.00897035, - "score": 0.01736937 + "bias": -0.0186728, + "spread": 0.02439882, + "score": 0.03072419 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00394877, - "spread": 0.00945407, - "score": 0.01024559 + "bias": 0.00398521, + "spread": 0.00758029, + "score": 0.00856404 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00793958, - "spread": 0.00892941, - "score": 0.01194869 + "bias": 0.00893284, + "spread": 0.00949766, + "score": 0.01303845 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00187509, - "spread": 0.00202787, - "score": 0.00276192 + "bias": -0.00042714, + "spread": 0.00284201, + "score": 0.00287393 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00212776, - "spread": 0.00530989, - "score": 0.00572034 + "bias": 9.34e-05, + "spread": 0.01170781, + "score": 0.01170818 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00276168, - "spread": 0.00630778, - "score": 0.00688585 + "bias": 0.00831364, + "spread": 0.00602384, + "score": 0.01026661 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.00227809, + "bias": 0.00558227, "spread": 0.0, - "score": 0.00227809 + "score": 0.00558227 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00519397, - "spread": 0.00464831, - "score": 0.00697023 + "bias": 0.00356446, + "spread": 0.00608342, + "score": 0.00705077 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00170802, - "spread": 0.00259959, - "score": 0.0031105 + "bias": 0.00630346, + "spread": 0.00494666, + "score": 0.00801268 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00215731, - "spread": 0.00271298, - "score": 0.00346616 + "bias": 0.00184534, + "spread": 0.00300262, + "score": 0.00352434 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00091056, - "spread": 0.0063966, - "score": 0.00646108 + "bias": 0.00291707, + "spread": 0.00706345, + "score": 0.00764209 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00163437, - "spread": 0.01443033, - "score": 0.01452259 + "bias": 0.00328774, + "spread": 0.01828265, + "score": 0.01857591 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.00113013, - "spread": 0.03889078, - "score": 0.0389072 + "bias": -0.00217857, + "spread": 0.02722173, + "score": 0.02730876 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.07345375, - "spread": 0.14705128, - "score": 0.16437619 + "bias": 0.08392288, + "spread": 0.17724026, + "score": 0.19610497 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.52182479, - "spread": 1.16038772, - "score": 1.27232102 + "bias": 0.75699048, + "spread": 1.60015784, + "score": 1.7701807 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.10537063, - "spread": 0.18412435, - "score": 0.21214322 + "bias": -0.12112974, + "spread": 0.21259367, + "score": 0.24468037 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.04174168, - "spread": 0.10226973, - "score": 0.11046024 + "bias": 0.03149044, + "spread": 0.28908455, + "score": 0.29079464 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00245592, - "spread": 0.00957754, - "score": 0.00988741 + "bias": 0.00625461, + "spread": 0.00836392, + "score": 0.01044391 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00322219, - "spread": 0.00464311, - "score": 0.00565164 + "bias": 0.00700495, + "spread": 0.00504481, + "score": 0.00863246 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.008385, - "spread": 0.00863245, - "score": 0.01203443 + "bias": 0.01083345, + "spread": 0.00823905, + "score": 0.0136105 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00156067, - "spread": 0.00270316, - "score": 0.00312134 + "bias": 0.00248604, + "spread": 0.00430595, + "score": 0.00497208 }, { "profile": "cp_0pct", @@ -505,9 +505,9 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.0640303, - "spread": 0.09973422, - "score": 0.11851917 + "bias": -0.08637061, + "spread": 0.09447452, + "score": 0.12800514 }, { "profile": "cp_0pct", @@ -541,225 +541,225 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00648665, - "spread": 0.01111695, - "score": 0.01287102 + "bias": 0.00050556, + "spread": 0.0159, + "score": 0.01590803 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00166205, - "spread": 0.00495989, - "score": 0.00523095 + "bias": 0.00352668, + "spread": 0.00745411, + "score": 0.00824628 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00484893, - "spread": 0.00517295, - "score": 0.00709024 + "bias": 0.00030031, + "spread": 0.00660401, + "score": 0.00661084 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "overall", "condition_bin": "overall", - "bias": 0.00206006, - "spread": 0.00188167, - "score": 0.00279008 + "bias": 0.00265563, + "spread": 0.00261342, + "score": 0.0037259 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00602033, - "spread": 0.00920072, - "score": 0.01099534 + "bias": 0.01014936, + "spread": 0.01389404, + "score": 0.01720621 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00210015, - "spread": 0.01087959, - "score": 0.01108044 + "bias": 0.00493328, + "spread": 0.01255725, + "score": 0.01349154 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00739383, - "spread": 0.01100454, - "score": 0.01325777 + "bias": 0.00156499, + "spread": 0.00855846, + "score": 0.00870037 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00024901, - "spread": 0.00309911, - "score": 0.00310909 + "bias": 0.0002728, + "spread": 0.00727035, + "score": 0.00727547 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00666666, - "spread": 0.01519318, - "score": 0.01659148 + "bias": -0.00060258, + "spread": 0.01279323, + "score": 0.01280741 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00557358, - "spread": 0.0103453, - "score": 0.01175117 + "bias": 0.00500945, + "spread": 0.00612624, + "score": 0.00791362 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.00385005, - "spread": 0.00063624, - "score": 0.00390226 + "bias": 0.0004054, + "spread": 0.00153901, + "score": 0.00159151 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.0068695, - "spread": 0.01246243, - "score": 0.01423033 + "bias": -0.00821503, + "spread": 0.01226003, + "score": 0.01475788 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00450247, - "spread": 0.00389314, - "score": 0.00595221 + "bias": 0.00444555, + "spread": 0.00184998, + "score": 0.00481512 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.0006468, - "spread": 0.00126723, - "score": 0.00142275 + "bias": -0.00043336, + "spread": 0.00429346, + "score": 0.00431528 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.0012469, - "spread": 0.00957785, - "score": 0.00965867 + "bias": 0.00231836, + "spread": 0.01131725, + "score": 0.01155227 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00521505, - "spread": 0.01738861, - "score": 0.0181538 + "bias": 0.00655879, + "spread": 0.0206258, + "score": 0.02164351 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.02658984, - "spread": 0.04058415, - "score": 0.048519 + "bias": -0.01164007, + "spread": 0.04212789, + "score": 0.04370641 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.08241847, - "spread": 0.10913265, - "score": 0.13675796 + "bias": 0.09012323, + "spread": 0.1017169, + "score": 0.13589895 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.20079161, - "spread": 0.39746764, - "score": 0.4453064 + "bias": 3.27521494, + "spread": 5.37614971, + "score": 6.29523777 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.02701868, - "spread": 0.0517155, - "score": 0.05834811 + "bias": -0.00972895, + "spread": 0.01960865, + "score": 0.02188953 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.09208905, - "spread": 0.07732789, - "score": 0.12024972 + "bias": -0.14269953, + "spread": 0.04011553, + "score": 0.14823094 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00221516, - "spread": 0.00702572, - "score": 0.00736666 + "bias": 0.00065993, + "spread": 0.00633946, + "score": 0.00637372 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00638987, - "spread": 0.00825524, - "score": 0.01043932 + "bias": 0.00760699, + "spread": 0.00986253, + "score": 0.01245535 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.01123526, - "spread": 0.01156734, - "score": 0.01612558 + "bias": 0.01064525, + "spread": 0.0117579, + "score": 0.01586094 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.0021629, - "spread": 0.00464052, - "score": 0.00511982 + "bias": 0.00084567, + "spread": 0.00380776, + "score": 0.00390053 }, { "profile": "cp_0pct", @@ -775,9 +775,9 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.0999781, - "spread": 0.10776336, - "score": 0.14699851 + "bias": -0.09741323, + "spread": 0.11968627, + "score": 0.15431831 }, { "profile": "cp_0pct", @@ -811,252 +811,252 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00065364, - "spread": 0.01397082, - "score": 0.0139861 + "bias": 0.01104127, + "spread": 0.0156077, + "score": 0.01911831 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00198026, - "spread": 0.00946999, - "score": 0.00967482 + "bias": 0.00029672, + "spread": 0.00997437, + "score": 0.00997878 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00156782, - "spread": 0.00740832, - "score": 0.00757241 + "bias": -0.00228957, + "spread": 0.00895844, + "score": 0.00924639 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "overall", "condition_bin": "overall", - "bias": 0.00411231, - "spread": 0.00186471, - "score": 0.00451533 + "bias": 0.00477967, + "spread": 0.00349488, + "score": 0.0059211 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00733822, - "spread": 0.01301467, - "score": 0.01494092 + "bias": 0.0084678, + "spread": 0.01885035, + "score": 0.02066493 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00240288, - "spread": 0.00254221, - "score": 0.00349809 + "bias": 0.00256022, + "spread": 0.00457053, + "score": 0.00523875 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.0075062, - "spread": 0.00603545, - "score": 0.00963171 + "bias": 0.00650462, + "spread": 0.00667694, + "score": 0.00932156 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00049947, - "spread": 0.00181329, - "score": 0.00188082 + "bias": 0.00216907, + "spread": 0.00142809, + "score": 0.00259698 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00316145, - "spread": 0.00932874, - "score": 0.00984988 + "bias": 0.00472091, + "spread": 0.007238, + "score": 0.00864151 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00504652, - "spread": 0.00824801, - "score": 0.00966939 + "bias": 0.00671344, + "spread": 0.00485733, + "score": 0.00828637 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.00458731, - "spread": 0.00216052, - "score": 0.00507063 + "bias": 0.00263608, + "spread": 0.0026124, + "score": 0.00371127 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00195755, - "spread": 0.00774742, - "score": 0.00799091 + "bias": -0.00229577, + "spread": 0.01127389, + "score": 0.01150527 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00335936, - "spread": 0.00154154, - "score": 0.00369617 + "bias": 0.00424472, + "spread": 0.00258782, + "score": 0.00497137 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00209699, - "spread": 0.0018656, - "score": 0.00280675 + "bias": 0.00352246, + "spread": 0.00301907, + "score": 0.00463924 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00807103, - "spread": 0.00614767, - "score": 0.01014571 + "bias": 0.00623552, + "spread": 0.00808551, + "score": 0.01021064 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01264494, - "spread": 0.00688185, - "score": 0.01439633 + "bias": 0.00771069, + "spread": 0.01101706, + "score": 0.01344732 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.01232468, - "spread": 0.01250179, - "score": 0.01755541 + "bias": 0.00885845, + "spread": 0.01587356, + "score": 0.01817807 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.08136692, - "spread": 0.07776325, - "score": 0.11255087 + "bias": 0.07210198, + "spread": 0.05577982, + "score": 0.09115966 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.19339617, - "spread": 0.12203664, - "score": 0.22868105 + "bias": 0.17348659, + "spread": 0.10746497, + "score": 0.20407429 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.01706249, - "spread": 0.40158029, - "score": 0.4019426 + "bias": -0.02251975, + "spread": 0.31692455, + "score": 0.31772363 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.11354135, - "spread": 0.10967945, - "score": 0.15786456 + "bias": -0.04409531, + "spread": 0.18590225, + "score": 0.19106031 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00157381, - "spread": 0.00322742, - "score": 0.0035907 + "bias": 0.00193263, + "spread": 0.00475088, + "score": 0.00512893 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.0045376, - "spread": 0.00497337, - "score": 0.00673233 + "bias": 0.00575072, + "spread": 0.00630846, + "score": 0.00853624 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00614567, - "spread": 0.00436994, - "score": 0.00754093 + "bias": 0.00830834, + "spread": 0.0067717, + "score": 0.01071841 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00376523, - "spread": 0.0023262, - "score": 0.00442585 + "bias": 0.00493903, + "spread": 0.00428404, + "score": 0.00653812 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 0.00137305, - "spread": 0.00237819, - "score": 0.0027461 + "bias": 0.00112004, + "spread": 0.00193996, + "score": 0.00224007 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.11375675, - "spread": 0.06676093, - "score": 0.13190003 + "bias": -0.11557064, + "spread": 0.08294415, + "score": 0.14225437 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": 0.00036526, - "spread": 0.00063265, - "score": 0.00073052 + "bias": 0.00156328, + "spread": 0.00270767, + "score": 0.00312655 }, { "profile": "cp_0pct", @@ -1081,252 +1081,252 @@ "campaign_months": 6, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00942507, - "spread": 0.00772492, - "score": 0.01218632 + "bias": 0.01146192, + "spread": 0.01022109, + "score": 0.01535728 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.0028878, - "spread": 0.0049096, - "score": 0.00569593 + "bias": 0.00510371, + "spread": 0.00302794, + "score": 0.00593433 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00408972, - "spread": 0.00636249, - "score": 0.00756354 + "bias": 0.00396519, + "spread": 0.00591568, + "score": 0.00712166 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "overall", "condition_bin": "overall", - "bias": 0.00096213, - "spread": 0.00334636, - "score": 0.00348193 + "bias": 0.0012846, + "spread": 0.00195641, + "score": 0.00234045 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.00247886, - "spread": 0.00503641, - "score": 0.00561339 + "bias": -0.00646735, + "spread": 0.00723115, + "score": 0.00970135 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00079785, - "spread": 0.00642739, - "score": 0.00647672 + "bias": 0.0009486, + "spread": 0.00455901, + "score": 0.00465666 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00229722, - "spread": 0.00113638, - "score": 0.00256293 + "bias": 0.00159808, + "spread": 0.0009473, + "score": 0.00185775 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00019342, - "spread": 0.00026237, - "score": 0.00032596 + "bias": 0.00066073, + "spread": 0.00107252, + "score": 0.00125971 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00094792, - "spread": 0.00718445, - "score": 0.00724672 + "bias": 0.0018825, + "spread": 0.00307082, + "score": 0.00360191 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00175987, - "spread": 0.0070516, - "score": 0.00726789 + "bias": 0.00305039, + "spread": 0.00493353, + "score": 0.0058004 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.00096213, - "spread": 0.00334636, - "score": 0.00348193 + "bias": 0.0012846, + "spread": 0.00195641, + "score": 0.00234045 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00245477, - "spread": 0.00332694, - "score": 0.00413454 + "bias": -0.00315178, + "spread": 0.00271697, + "score": 0.00416121 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -6.33e-06, - "spread": 0.0024154, - "score": 0.00241541 + "bias": 0.00042805, + "spread": 0.00199258, + "score": 0.00203804 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -2.833e-05, - "spread": 0.004114, - "score": 0.0041141 + "bias": 0.00105278, + "spread": 0.00203022, + "score": 0.00228695 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00379788, - "spread": 0.00458207, - "score": 0.00595141 + "bias": 0.00208439, + "spread": 0.00457137, + "score": 0.00502415 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00965358, - "spread": 0.00655176, - "score": 0.01166692 + "bias": 0.00534022, + "spread": 0.00470285, + "score": 0.00711581 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.00101092, - "spread": 0.01234741, - "score": 0.01238872 + "bias": 0.0003605, + "spread": 0.01242656, + "score": 0.01243178 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.05178133, - "spread": 0.04400123, - "score": 0.06795156 + "bias": 0.0410025, + "spread": 0.03623111, + "score": 0.05471653 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.37419471, - "spread": 0.39392138, - "score": 0.54331918 + "bias": 0.3055943, + "spread": 0.34032839, + "score": 0.4573962 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.43925074, - "spread": 0.68039542, - "score": 0.80986366 + "bias": 0.37539459, + "spread": 0.57365057, + "score": 0.6855626 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.10378407, - "spread": 0.09157735, - "score": 0.13841078 + "bias": -0.06524726, + "spread": 0.0882168, + "score": 0.10972424 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00102035, - "spread": 0.00284173, - "score": 0.00301936 + "bias": 0.00130289, + "spread": 0.00193628, + "score": 0.00233382 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00137214, - "spread": 0.00245811, - "score": 0.00281515 + "bias": 0.00117363, + "spread": 0.00210087, + "score": 0.00240646 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00195442, - "spread": 0.00197181, - "score": 0.00277629 + "bias": 0.00200827, + "spread": 0.00225759, + "score": 0.00302157 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00050582, - "spread": 0.0007369, - "score": 0.0008938 + "bias": 0.00065989, + "spread": 0.00154882, + "score": 0.00168354 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 0.00140443, - "spread": 0.00325694, - "score": 0.00354685 + "bias": 0.00144552, + "spread": 0.00275757, + "score": 0.00311348 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.12418465, - "spread": 0.07005121, - "score": 0.1425798 + "bias": -0.12677568, + "spread": 0.07816698, + "score": 0.14893673 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": 0.00274051, - "spread": 0.01161615, - "score": 0.01193504 + "bias": 0.00399726, + "spread": 0.01317414, + "score": 0.01376721 }, { "profile": "cp_0pct", @@ -1351,90 +1351,90 @@ "campaign_months": 12, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.0013323, - "spread": 0.00721057, - "score": 0.00733262 + "bias": 0.00121975, + "spread": 0.00412995, + "score": 0.00430631 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00083095, - "spread": 0.00692504, - "score": 0.00697472 + "bias": 0.00163609, + "spread": 0.00414632, + "score": 0.00445744 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00076612, - "spread": 0.00728604, - "score": 0.00732621 + "bias": 0.00150955, + "spread": 0.00547756, + "score": 0.00568176 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "overall", "condition_bin": "overall", - "bias": -0.00384826, - "spread": 0.01034667, - "score": 0.01103914 + "bias": -0.00235534, + "spread": 0.00425894, + "score": 0.00486684 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.01798448, - "spread": 0.0248734, - "score": 0.03069409 + "bias": -0.02564824, + "spread": 0.03236671, + "score": 0.04129692 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00588722, - "spread": 0.02169078, - "score": 0.02247553 + "bias": 0.00630587, + "spread": 0.01423632, + "score": 0.01557038 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.02975194, - "spread": 0.02119502, - "score": 0.03652953 + "bias": 0.03246548, + "spread": 0.02031104, + "score": 0.0382955 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.0007384, - "spread": 0.00331792, - "score": 0.00339909 + "bias": -0.00168874, + "spread": 0.00414949, + "score": 0.00447997 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.02332791, - "spread": 0.02124848, - "score": 0.03155454 + "bias": -0.02067589, + "spread": 0.01422838, + "score": 0.02509859 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00700859, - "spread": 0.01282926, - "score": 0.01461883 + "bias": -0.00639105, + "spread": 0.01148807, + "score": 0.01314615 }, { "profile": "cp_minus_10pct", @@ -1450,126 +1450,126 @@ "campaign_months": 1, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.01113027, - "spread": 0.01031433, - "score": 0.01517459 + "bias": -0.00864879, + "spread": 0.01587072, + "score": 0.01807433 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00733288, - "spread": 0.0109032, - "score": 0.01313966 + "bias": -0.00713629, + "spread": 0.00805104, + "score": 0.01075852 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00467733, - "spread": 0.00938052, - "score": 0.01048197 + "bias": -0.00355167, + "spread": 0.00589741, + "score": 0.00688432 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.0011806, - "spread": 0.01880055, - "score": 0.01883758 + "bias": 0.00162683, + "spread": 0.00752795, + "score": 0.00770173 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01185137, - "spread": 0.03718113, - "score": 0.03902424 + "bias": 0.019983, + "spread": 0.0186548, + "score": 0.02733719 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.03172579, - "spread": 0.02103551, - "score": 0.03806598 + "bias": 0.03985417, + "spread": 0.0444423, + "score": 0.05969483 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.30555752, - "spread": 0.54136676, - "score": 0.6216457 + "bias": 0.30213106, + "spread": 0.57289351, + "score": 0.64768059 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.00941036, - "spread": 0.11028173, - "score": 0.11068249 + "bias": -0.00976159, + "spread": 0.09029662, + "score": 0.09082273 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.06506386, - "spread": 0.10149481, - "score": 0.12055912 + "bias": 0.06655678, + "spread": 0.10457749, + "score": 0.1239607 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.06005579, - "spread": 0.13610279, - "score": 0.1487638 + "bias": -0.00032368, + "spread": 0.18368009, + "score": 0.18368038 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00367154, - "spread": 0.02047189, - "score": 0.02079852 + "bias": 0.00440945, + "spread": 0.01632822, + "score": 0.01691313 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": -0.0001943, - "spread": 0.01860817, - "score": 0.01860919 + "bias": -0.00036536, + "spread": 0.01992764, + "score": 0.01993099 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00495985, - "spread": 0.01078486, - "score": 0.01187069 + "bias": 0.00445324, + "spread": 0.00936486, + "score": 0.01036977 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00819065, - "spread": 0.01388027, - "score": 0.01611672 + "bias": 0.00593528, + "spread": 0.00997414, + "score": 0.01160651 }, { "profile": "cp_minus_10pct", @@ -1585,9 +1585,9 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.0764984, - "spread": 0.07298385, - "score": 0.10572912 + "bias": -0.06786193, + "spread": 0.07180701, + "score": 0.09880024 }, { "profile": "cp_minus_10pct", @@ -1621,225 +1621,225 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00775252, - "spread": 0.01259731, - "score": 0.01479168 + "bias": -0.00856091, + "spread": 0.01950782, + "score": 0.02130363 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00378876, - "spread": 0.01256742, - "score": 0.01312611 + "bias": -0.00200712, + "spread": 0.0136243, + "score": 0.01377135 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.01055944, - "spread": 0.0165097, - "score": 0.01959776 + "bias": -0.00785821, + "spread": 0.01080651, + "score": 0.01336159 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "overall", "condition_bin": "overall", - "bias": -4.35e-06, - "spread": 0.00177896, - "score": 0.00177896 + "bias": 0.00337847, + "spread": 0.00444417, + "score": 0.00558254 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.01480279, - "spread": 0.01052187, - "score": 0.01816128 + "bias": -0.01112897, + "spread": 0.02409313, + "score": 0.02653927 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00627146, - "spread": 0.01078968, - "score": 0.01247991 + "bias": 0.01410967, + "spread": 0.00922894, + "score": 0.0168599 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.01892891, - "spread": 0.00728206, - "score": 0.02028132 + "bias": 0.01646452, + "spread": 0.00690335, + "score": 0.0178532 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00734198, - "spread": 0.00315874, - "score": 0.00799264 + "bias": -0.00330096, + "spread": 0.00443658, + "score": 0.00552988 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00176811, - "spread": 0.00454359, - "score": 0.00487549 + "bias": -0.00204449, + "spread": 0.01234676, + "score": 0.01251489 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00034386, - "spread": 0.00620007, - "score": 0.0062096 + "bias": 0.00403208, + "spread": 0.00764101, + "score": 0.0086396 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.02866147, + "bias": 0.0318449, "spread": 0.0, - "score": 0.02866147 + "score": 0.0318449 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.01051748, - "spread": 0.00851979, - "score": 0.0135353 + "bias": 0.00853592, + "spread": 0.01009849, + "score": 0.01322276 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00220454, - "spread": 0.00275592, - "score": 0.00352917 + "bias": 0.00227074, + "spread": 0.00316563, + "score": 0.00389583 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.001397, - "spread": 0.00367481, - "score": 0.0039314 + "bias": 0.00240579, + "spread": 0.00337743, + "score": 0.00414667 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00432644, - "spread": 0.00442381, - "score": 0.00618775 + "bias": 0.00487426, + "spread": 0.00853506, + "score": 0.00982881 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00420609, - "spread": 0.01088413, - "score": 0.01166857 + "bias": 0.0076908, + "spread": 0.01947584, + "score": 0.02093935 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.00539869, - "spread": 0.03605402, - "score": 0.03645598 + "bias": 0.00663679, + "spread": 0.02731158, + "score": 0.02810639 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.07956192, - "spread": 0.13289333, - "score": 0.15488944 + "bias": 0.09831116, + "spread": 0.16414914, + "score": 0.19133746 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.47777783, - "spread": 1.11158837, - "score": 1.2099175 + "bias": 0.72027022, + "spread": 1.55667721, + "score": 1.71523558 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.33054658, - "spread": 0.94808219, - "score": 1.00405223 + "bias": 0.32953775, + "spread": 0.94610103, + "score": 1.00184943 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.08463635, - "spread": 0.11013757, - "score": 0.13890139 + "bias": -0.03875981, + "spread": 0.11869726, + "score": 0.12486537 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00412392, - "spread": 0.01251363, - "score": 0.01317564 + "bias": 0.00737141, + "spread": 0.01057424, + "score": 0.01289001 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": -0.00281529, - "spread": 0.00329599, - "score": 0.00433467 + "bias": 0.00195273, + "spread": 0.00371908, + "score": 0.00420056 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00532604, - "spread": 0.00658217, - "score": 0.00846709 + "bias": 0.00901693, + "spread": 0.00672188, + "score": 0.01124672 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00277639, - "spread": 0.00460643, - "score": 0.00537844 + "bias": 0.00374679, + "spread": 0.0062872, + "score": 0.00731897 }, { "profile": "cp_minus_10pct", @@ -1855,9 +1855,9 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.02577533, - "spread": 0.06145086, - "score": 0.06663764 + "bias": -0.04260139, + "spread": 0.06323236, + "score": 0.07624441 }, { "profile": "cp_minus_10pct", @@ -1891,225 +1891,225 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00639839, - "spread": 0.01042968, - "score": 0.01223591 + "bias": 0.00126544, + "spread": 0.01685659, + "score": 0.01690402 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00151072, - "spread": 0.00485983, - "score": 0.00508923 + "bias": 0.00224502, + "spread": 0.00830232, + "score": 0.0086005 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.0025416, - "spread": 0.00465052, - "score": 0.00529972 + "bias": 0.00150066, + "spread": 0.00741209, + "score": 0.00756247 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "overall", "condition_bin": "overall", - "bias": 0.00199417, - "spread": 0.00175822, - "score": 0.00265858 + "bias": 0.00222104, + "spread": 0.00179982, + "score": 0.00285874 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00625794, - "spread": 0.01457244, - "score": 0.01585932 + "bias": 0.0088847, + "spread": 0.01325639, + "score": 0.01595837 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.0116314, - "spread": 0.00909188, - "score": 0.01476319 + "bias": 0.01133818, + "spread": 0.01417875, + "score": 0.01815465 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.01927845, - "spread": 0.01282402, - "score": 0.02315414 + "bias": 0.01146926, + "spread": 0.00723426, + "score": 0.01356018 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00511103, - "spread": 0.00367646, - "score": 0.00629595 + "bias": -0.00410527, + "spread": 0.00675038, + "score": 0.00790068 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00763161, - "spread": 0.01493865, - "score": 0.01677512 + "bias": -0.00361143, + "spread": 0.01181371, + "score": 0.01235339 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00378023, - "spread": 0.00905931, - "score": 0.00981637 + "bias": 0.00349343, + "spread": 0.0039206, + "score": 0.00525121 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.01662755, - "spread": 0.04697228, - "score": 0.04982841 + "bias": -0.0196466, + "spread": 0.04646088, + "score": 0.05044405 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.01049674, - "spread": 0.0125645, - "score": 0.01637218 + "bias": -0.01013196, + "spread": 0.01410322, + "score": 0.01736541 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00062672, - "spread": 0.00374288, - "score": 0.00379498 + "bias": 0.00077184, + "spread": 0.00282975, + "score": 0.00293312 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.0001249, - "spread": 0.00245736, - "score": 0.00246054 + "bias": 0.00018092, + "spread": 0.00219493, + "score": 0.00220237 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00532275, - "spread": 0.00890481, - "score": 0.01037436 + "bias": 0.00472212, + "spread": 0.01043072, + "score": 0.01144981 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01014959, - "spread": 0.0156897, - "score": 0.01868638 + "bias": 0.00901832, + "spread": 0.01755668, + "score": 0.01973746 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.01824445, - "spread": 0.03792589, - "score": 0.04208602 + "bias": -0.00795758, + "spread": 0.03833171, + "score": 0.03914899 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.08918614, - "spread": 0.08933055, - "score": 0.1262304 + "bias": 0.0878722, + "spread": 0.09294741, + "score": 0.12790912 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.12722958, - "spread": 0.3907416, - "score": 0.41093353 + "bias": 0.04065402, + "spread": 0.27998259, + "score": 0.28291872 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.08554023, - "spread": 0.26596105, - "score": 0.27937862 + "bias": -0.07985383, + "spread": 0.27376005, + "score": 0.28516872 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.07155598, - "spread": 0.1889484, - "score": 0.20204394 + "bias": -0.05125411, + "spread": 0.11048262, + "score": 0.12179242 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00539685, - "spread": 0.01076901, - "score": 0.01204564 + "bias": 0.00267391, + "spread": 0.00852521, + "score": 0.00893471 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": -0.00047273, - "spread": 0.00934848, - "score": 0.00936043 + "bias": 0.0025576, + "spread": 0.00977662, + "score": 0.01010562 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00720042, - "spread": 0.01102831, - "score": 0.01317079 + "bias": 0.00837095, + "spread": 0.01065551, + "score": 0.01355037 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00075518, - "spread": 0.00279435, - "score": 0.00289459 + "bias": 0.00033976, + "spread": 0.00080428, + "score": 0.0008731 }, { "profile": "cp_minus_10pct", @@ -2125,9 +2125,9 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.05023621, - "spread": 0.0726888, - "score": 0.08835914 + "bias": -0.05747686, + "spread": 0.07082191, + "score": 0.09121037 }, { "profile": "cp_minus_10pct", @@ -2161,252 +2161,252 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00039031, - "spread": 0.016853, - "score": 0.01685751 + "bias": 0.00950343, + "spread": 0.01418027, + "score": 0.0170703 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00243369, - "spread": 0.01013007, - "score": 0.01041831 + "bias": 0.00011355, + "spread": 0.00897577, + "score": 0.00897649 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00084799, - "spread": 0.00735528, - "score": 0.007404 + "bias": -0.0012218, + "spread": 0.00868065, + "score": 0.00876621 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "overall", "condition_bin": "overall", - "bias": 0.0039361, - "spread": 0.0017386, - "score": 0.00430297 + "bias": 0.00449493, + "spread": 0.00286854, + "score": 0.00533226 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00690228, - "spread": 0.01429905, - "score": 0.01587779 + "bias": 0.00604891, + "spread": 0.01543821, + "score": 0.01658095 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.01125114, - "spread": 0.00143332, - "score": 0.01134207 + "bias": 0.00840115, + "spread": 0.00409406, + "score": 0.00934562 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.01843772, - "spread": 0.00634155, - "score": 0.01949781 + "bias": 0.01661878, + "spread": 0.0067418, + "score": 0.0179342 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00309207, - "spread": 0.0022804, - "score": 0.00384202 + "bias": -0.00085856, + "spread": 0.00176223, + "score": 0.00196025 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00201187, - "spread": 0.00842594, - "score": 0.0086628 + "bias": 0.0027989, + "spread": 0.00682785, + "score": 0.00737925 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00325204, - "spread": 0.00616672, - "score": 0.00697167 + "bias": 0.0049183, + "spread": 0.00446871, + "score": 0.00664522 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.03650845, - "spread": 0.00472694, - "score": 0.03681319 + "bias": 0.03481797, + "spread": 0.00500648, + "score": 0.03517607 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00086353, - "spread": 0.00727372, - "score": 0.0073248 + "bias": -4.208e-05, + "spread": 0.00881814, + "score": 0.00881824 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00119523, - "spread": 0.00103178, - "score": 0.00157897 + "bias": 0.0023464, + "spread": 0.00177102, + "score": 0.00293974 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00193604, - "spread": 0.00233197, - "score": 0.0030309 + "bias": 0.00311211, + "spread": 0.00192669, + "score": 0.00366024 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.01034235, - "spread": 0.00579762, - "score": 0.0118565 + "bias": 0.00836311, + "spread": 0.00691373, + "score": 0.01085086 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01578415, - "spread": 0.00556343, - "score": 0.01673592 + "bias": 0.01021093, + "spread": 0.01035562, + "score": 0.01454311 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.01509941, - "spread": 0.0125347, - "score": 0.01962424 + "bias": 0.01289287, + "spread": 0.01450336, + "score": 0.0194055 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.08265843, - "spread": 0.06713304, - "score": 0.10648597 + "bias": 0.07684761, + "spread": 0.05026157, + "score": 0.09182473 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.04712131, - "spread": 0.35042542, - "score": 0.3535794 + "bias": 0.02956985, + "spread": 0.34113986, + "score": 0.34241902 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.11349187, - "spread": 0.36783953, - "score": 0.38494977 + "bias": -0.12343933, + "spread": 0.29183636, + "score": 0.31686864 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.03984096, - "spread": 0.1732238, - "score": 0.17774641 + "bias": -0.016323, + "spread": 0.1660525, + "score": 0.16685285 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00459812, - "spread": 0.00425721, - "score": 0.0062663 + "bias": 0.00451373, + "spread": 0.00450141, + "score": 0.00637467 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": -0.0009016, - "spread": 0.00511846, - "score": 0.00519726 + "bias": 0.00153391, + "spread": 0.00657372, + "score": 0.00675031 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00337356, - "spread": 0.00411458, - "score": 0.00532078 + "bias": 0.00657613, + "spread": 0.00612316, + "score": 0.00898547 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00192021, - "spread": 0.00048286, - "score": 0.00197999 + "bias": 0.00407268, + "spread": 0.00297864, + "score": 0.0050457 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 0.00253225, - "spread": 0.00243456, - "score": 0.00351275 + "bias": 0.00277022, + "spread": 0.00269572, + "score": 0.00386537 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.05547811, - "spread": 0.03051691, - "score": 0.06331747 + "bias": -0.05798103, + "spread": 0.03550391, + "score": 0.0679877 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": 0.00597601, - "spread": 0.00519317, - "score": 0.00791718 + "bias": 0.00753456, + "spread": 0.00594359, + "score": 0.00959666 }, { "profile": "cp_minus_10pct", @@ -2431,252 +2431,252 @@ "campaign_months": 6, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.008709, - "spread": 0.00788457, - "score": 0.0117479 + "bias": 0.00978248, + "spread": 0.00861752, + "score": 0.01303681 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00265028, - "spread": 0.00498366, - "score": 0.00564455 + "bias": 0.00366619, + "spread": 0.00296965, + "score": 0.00471802 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00535685, - "spread": 0.00566817, - "score": 0.00779897 + "bias": 0.00489926, + "spread": 0.00605655, + "score": 0.00779003 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "overall", "condition_bin": "overall", - "bias": 0.00097627, - "spread": 0.00315056, - "score": 0.00329836 + "bias": 0.00125687, + "spread": 0.00190378, + "score": 0.00228125 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.00146815, - "spread": 0.00548167, - "score": 0.00567488 + "bias": -0.00537363, + "spread": 0.0072768, + "score": 0.00904586 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00838365, - "spread": 0.00510736, - "score": 0.00981686 + "bias": 0.00694062, + "spread": 0.00426121, + "score": 0.00814434 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.01161496, - "spread": 0.00258368, - "score": 0.01189886 + "bias": 0.00897477, + "spread": 0.00165555, + "score": 0.00912619 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00316989, - "spread": 0.00024445, - "score": 0.0031793 + "bias": -0.00157563, + "spread": 0.00093053, + "score": 0.00182989 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00033869, - "spread": 0.00652522, - "score": 0.006534 + "bias": 0.00024369, + "spread": 0.00375653, + "score": 0.00376442 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00082315, - "spread": 0.00668295, - "score": 0.00673346 + "bias": 0.00177126, + "spread": 0.00435876, + "score": 0.00470491 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.00779326, - "spread": 0.05332963, - "score": 0.05389605 + "bias": -0.00751265, + "spread": 0.05204062, + "score": 0.05258009 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00106047, - "spread": 0.0049238, - "score": 0.00503671 + "bias": 0.0013147, + "spread": 0.00219886, + "score": 0.00256192 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00073637, - "spread": 0.00227546, - "score": 0.00239165 + "bias": 0.00017007, + "spread": 0.00176385, + "score": 0.00177203 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00065292, - "spread": 0.00401113, - "score": 0.00406392 + "bias": 0.00028978, + "spread": 0.00208746, + "score": 0.00210748 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00582588, - "spread": 0.00395896, - "score": 0.00704373 + "bias": 0.00338249, + "spread": 0.00442744, + "score": 0.00557167 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01230101, - "spread": 0.00554853, - "score": 0.01349448 + "bias": 0.00715844, + "spread": 0.0048179, + "score": 0.00862875 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.00702036, - "spread": 0.01124452, - "score": 0.01325612 + "bias": 0.00446376, + "spread": 0.01321258, + "score": 0.01394623 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.05487793, - "spread": 0.04017211, - "score": 0.06801019 + "bias": 0.04171882, + "spread": 0.02953425, + "score": 0.05111489 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.34051897, - "spread": 0.32206093, - "score": 0.4686965 + "bias": 0.27112725, + "spread": 0.25289407, + "score": 0.37076326 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.08474655, - "spread": 0.98931339, - "score": 0.99293654 + "bias": 0.04492286, + "spread": 0.93229133, + "score": 0.93337301 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.08501932, - "spread": 0.08819053, - "score": 0.12249838 + "bias": -0.05331342, + "spread": 0.10564727, + "score": 0.11833709 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00452846, - "spread": 0.00219194, - "score": 0.00503106 + "bias": 0.00426429, + "spread": 0.00140984, + "score": 0.0044913 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": -0.002707, - "spread": 0.00293234, - "score": 0.0039908 + "bias": -0.00187745, + "spread": 0.00300575, + "score": 0.00354392 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00013067, - "spread": 0.0018325, - "score": 0.00183716 + "bias": 0.00088053, + "spread": 0.00218311, + "score": 0.002354 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": -0.00064964, - "spread": 0.00155113, - "score": 0.00168167 + "bias": -0.0002062, + "spread": 0.00171811, + "score": 0.00173044 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 0.00166555, - "spread": 0.00518857, - "score": 0.00544934 + "bias": 0.00222121, + "spread": 0.00491614, + "score": 0.00539465 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.06933752, - "spread": 0.03117433, - "score": 0.07602323 + "bias": -0.07258494, + "spread": 0.03888162, + "score": 0.0823429 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": 0.00624964, - "spread": 0.01389321, - "score": 0.01523415 + "bias": 0.00816485, + "spread": 0.01528966, + "score": 0.01733316 }, { "profile": "cp_minus_10pct", @@ -2701,90 +2701,90 @@ "campaign_months": 12, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00133231, - "spread": 0.00734114, - "score": 0.00746106 + "bias": 0.00067879, + "spread": 0.00431204, + "score": 0.00436514 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 4.406e-05, - "spread": 0.00683774, - "score": 0.00683789 + "bias": 0.00085523, + "spread": 0.00471914, + "score": 0.00479601 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00197835, - "spread": 0.00678884, - "score": 0.00707123 + "bias": 0.00188566, + "spread": 0.00527424, + "score": 0.00560119 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "overall", "condition_bin": "overall", - "bias": -0.00469112, - "spread": 0.0119443, - "score": 0.01283249 + "bias": -0.00223447, + "spread": 0.00515167, + "score": 0.00561539 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.02252384, - "spread": 0.03645112, - "score": 0.04284866 + "bias": -0.02853577, + "spread": 0.04380962, + "score": 0.05228358 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00250423, - "spread": 0.02653637, - "score": 0.02665427 + "bias": -0.00169495, + "spread": 0.0105176, + "score": 0.0106533 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.01282654, - "spread": 0.02217634, - "score": 0.02561855 + "bias": 0.01250031, + "spread": 0.01699378, + "score": 0.02109612 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.0040571, - "spread": 0.004613, - "score": 0.00614328 + "bias": 0.0030612, + "spread": 0.00427067, + "score": 0.00525448 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.02797461, - "spread": 0.03122669, - "score": 0.04192476 + "bias": -0.01945009, + "spread": 0.01585639, + "score": 0.02509444 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.01081487, - "spread": 0.00933839, - "score": 0.0142887 + "bias": -0.00898036, + "spread": 0.01259925, + "score": 0.01547217 }, { "profile": "cp_plus_10pct", @@ -2800,126 +2800,126 @@ "campaign_months": 1, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.0118305, - "spread": 0.02754161, - "score": 0.02997501 + "bias": -0.0067249, + "spread": 0.02408912, + "score": 0.0250102 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.01047774, - "spread": 0.01762825, - "score": 0.02050703 + "bias": -0.00873727, + "spread": 0.01396184, + "score": 0.01647036 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00547375, - "spread": 0.00902041, - "score": 0.01055129 + "bias": -0.00366919, + "spread": 0.005489, + "score": 0.00660242 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00063771, - "spread": 0.02136928, - "score": 0.02137879 + "bias": 0.00075789, + "spread": 0.00967491, + "score": 0.00970455 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01044814, - "spread": 0.04400134, - "score": 0.04522479 + "bias": 0.01931746, + "spread": 0.02567242, + "score": 0.03212845 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.02645185, - "spread": 0.01221653, - "score": 0.02913664 + "bias": 0.03116295, + "spread": 0.02783721, + "score": 0.04178564 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.37481409, - "spread": 0.74580417, - "score": 0.83469124 + "bias": 0.34407739, + "spread": 0.74831732, + "score": 0.82363102 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.05526605, - "spread": 0.06943106, - "score": 0.08874124 + "bias": 0.02310444, + "spread": 0.07182564, + "score": 0.07545024 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.07360324, - "spread": 0.10706847, - "score": 0.12992727 + "bias": -0.07114659, + "spread": 0.10304849, + "score": 0.12522312 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.06058553, - "spread": 0.08668914, - "score": 0.10576207 + "bias": 0.02717993, + "spread": 0.06029605, + "score": 0.06613897 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.0001018, - "spread": 0.01704679, - "score": 0.01704709 + "bias": 0.00046777, + "spread": 0.01138673, + "score": 0.01139633 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.01415104, - "spread": 0.0202714, - "score": 0.02472209 + "bias": 0.01255482, + "spread": 0.01881739, + "score": 0.02262118 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00454642, - "spread": 0.00952511, - "score": 0.01055451 + "bias": 0.00479644, + "spread": 0.00805175, + "score": 0.00937211 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00593339, - "spread": 0.0105849, - "score": 0.01213446 + "bias": 0.00390105, + "spread": 0.00706524, + "score": 0.00807068 }, { "profile": "cp_plus_10pct", @@ -2935,9 +2935,9 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.2254042, - "spread": 0.22408564, - "score": 0.31783868 + "bias": -0.20714539, + "spread": 0.22797342, + "score": 0.30802775 }, { "profile": "cp_plus_10pct", @@ -2971,225 +2971,225 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00740578, - "spread": 0.01663438, - "score": 0.01820847 + "bias": -0.0032801, + "spread": 0.01991846, + "score": 0.02018673 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00293746, - "spread": 0.01317416, - "score": 0.01349767 + "bias": -0.00052673, + "spread": 0.01236766, + "score": 0.01237887 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.01674645, - "spread": 0.01850764, - "score": 0.02495949 + "bias": -0.01255121, + "spread": 0.01010137, + "score": 0.0161112 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "overall", "condition_bin": "overall", - "bias": -0.00014375, - "spread": 0.00210202, - "score": 0.00210693 + "bias": 0.00436369, + "spread": 0.00512561, + "score": 0.00673154 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.02452249, - "spread": 0.02011447, - "score": 0.03171663 + "bias": -0.02284812, + "spread": 0.03024985, + "score": 0.03790897 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.01124838, - "spread": 0.00995243, - "score": 0.01501922 + "bias": -0.00211113, + "spread": 0.00821179, + "score": 0.00847882 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00494601, - "spread": 0.00792746, - "score": 0.00934386 + "bias": 0.00596563, + "spread": 0.01017404, + "score": 0.01179406 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00134241, - "spread": 0.00468343, - "score": 0.00487202 + "bias": 0.00190258, + "spread": 0.00305205, + "score": 0.0035965 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.0078345, - "spread": 0.0039501, - "score": 0.00877398 + "bias": -0.0017301, + "spread": 0.01184759, + "score": 0.01197325 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00055975, - "spread": 0.00485872, - "score": 0.00489086 + "bias": 0.00884329, + "spread": 0.00522836, + "score": 0.01027324 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.02410822, + "bias": -0.0202599, "spread": 0.0, - "score": 0.02410822 + "score": 0.0202599 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00112255, - "spread": 0.00539816, - "score": 0.00551364 + "bias": -0.00239707, + "spread": 0.00675275, + "score": 0.00716559 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.0059868, - "spread": 0.00492861, - "score": 0.00775455 + "bias": 0.01090348, + "spread": 0.00760539, + "score": 0.0132939 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00273603, - "spread": 0.00184004, - "score": 0.00329721 + "bias": 0.00173283, + "spread": 0.00372225, + "score": 0.00410583 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00254849, - "spread": 0.00847252, - "score": 0.0088475 + "bias": 0.00069563, + "spread": 0.0054, + "score": 0.00544462 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00869233, - "spread": 0.01618735, - "score": 0.01837353 + "bias": -0.00189064, + "spread": 0.01722051, + "score": 0.01732398 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.01281866, - "spread": 0.03652679, - "score": 0.03871078 + "bias": -0.0083316, + "spread": 0.02434653, + "score": 0.02573265 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.05697343, - "spread": 0.17676046, - "score": 0.18571546 + "bias": 0.07945347, + "spread": 0.20594212, + "score": 0.22073743 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.55560351, - "spread": 1.2501431, - "score": 1.36804716 + "bias": 0.79781182, + "spread": 1.68480792, + "score": 1.86415703 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.58740607, - "spread": 1.38642431, - "score": 1.50572848 + "bias": -0.6054926, + "spread": 1.4186641, + "score": 1.542475 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.05952777, - "spread": 0.17237228, - "score": 0.18236162 + "bias": 3.3623231, + "spread": 5.98999333, + "score": 6.86915109 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00081554, - "spread": 0.00504111, - "score": 0.00510666 + "bias": 0.00530493, + "spread": 0.00627884, + "score": 0.00821986 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00869986, - "spread": 0.00354601, - "score": 0.00939478 + "bias": 0.01181448, + "spread": 0.0048468, + "score": 0.01277002 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.01174806, - "spread": 0.00978863, - "score": 0.01529164 + "bias": 0.01380653, + "spread": 0.00913294, + "score": 0.01655388 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00174157, - "spread": 0.00321896, - "score": 0.00365989 + "bias": 0.00145868, + "spread": 0.00272898, + "score": 0.00309436 }, { "profile": "cp_plus_10pct", @@ -3205,9 +3205,9 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.1143688, - "spread": 0.12582742, - "score": 0.17003753 + "bias": -0.12889237, + "spread": 0.13023552, + "score": 0.18323355 }, { "profile": "cp_plus_10pct", @@ -3241,225 +3241,225 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.01070271, - "spread": 0.01629079, - "score": 0.01949199 + "bias": 0.00150655, + "spread": 0.01914025, + "score": 0.01919945 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00096938, - "spread": 0.00260803, - "score": 0.00278235 + "bias": 0.00410845, + "spread": 0.00871389, + "score": 0.00963386 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00705426, - "spread": 0.00539844, - "score": 0.00888289 + "bias": -0.00032168, + "spread": 0.00612406, + "score": 0.0061325 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "overall", "condition_bin": "overall", - "bias": 0.00212595, - "spread": 0.00200591, - "score": 0.00292289 + "bias": 0.0038976, + "spread": 0.00306528, + "score": 0.00495855 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00761734, - "spread": 0.01347733, - "score": 0.01548103 + "bias": 0.01069354, + "spread": 0.0150623, + "score": 0.01847227 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00336395, - "spread": 0.01308516, - "score": 0.01351065 + "bias": 0.00127698, + "spread": 0.01197738, + "score": 0.01204526 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00226052, - "spread": 0.0110063, - "score": 0.01123604 + "bias": -0.00105286, + "spread": 0.01036398, + "score": 0.01041732 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00146029, - "spread": 0.00331361, - "score": 0.00362111 + "bias": 0.00209294, + "spread": 0.00587628, + "score": 0.00623787 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00942877, - "spread": 0.01750438, - "score": 0.01988228 + "bias": -0.00130302, + "spread": 0.01364035, + "score": 0.01370245 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00427219, - "spread": 0.00912289, - "score": 0.01007366 + "bias": 0.00450484, + "spread": 0.0061225, + "score": 0.00760122 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.02432763, - "spread": 0.04824474, - "score": 0.05403136 + "bias": 0.02172092, + "spread": 0.04862513, + "score": 0.053256 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00678368, - "spread": 0.01397751, - "score": 0.0155367 + "bias": -0.00580381, + "spread": 0.014392, + "score": 0.01551818 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00792632, - "spread": 0.00474794, - "score": 0.00923956 + "bias": 0.00893747, + "spread": 0.00460184, + "score": 0.01005263 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00163904, - "spread": 0.0009257, - "score": 0.00188238 + "bias": -9.707e-05, + "spread": 0.00619793, + "score": 0.00619869 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00151043, - "spread": 0.01074864, - "score": 0.01085424 + "bias": 0.00029079, + "spread": 0.01171488, + "score": 0.01171848 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00041882, - "spread": 0.02340316, - "score": 0.0234069 + "bias": 0.00237407, + "spread": 0.02280066, + "score": 0.02292392 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.03139314, - "spread": 0.04830329, - "score": 0.05760848 + "bias": -0.01816385, + "spread": 0.05428813, + "score": 0.05724619 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.08704528, - "spread": 0.12343039, - "score": 0.15103623 + "bias": 0.08807424, + "spread": 0.10726208, + "score": 0.13878842 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.26477838, - "spread": 0.41353733, - "score": 0.49104044 + "bias": 1.82942415, + "spread": 2.88922109, + "score": 3.41970631 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.02631239, - "spread": 0.34255718, - "score": 0.34356624 + "bias": 0.03990353, + "spread": 0.32328432, + "score": 0.32573769 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.15160466, - "spread": 0.19594569, - "score": 0.24774722 + "bias": -0.16736257, + "spread": 0.04427028, + "score": 0.17311871 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.0009676, - "spread": 0.00562125, - "score": 0.00570392 + "bias": -0.00108699, + "spread": 0.00582797, + "score": 0.00592847 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.01088499, - "spread": 0.00973051, - "score": 0.0146002 + "bias": 0.01424046, + "spread": 0.01073451, + "score": 0.01783312 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.01202918, - "spread": 0.01393985, - "score": 0.01841251 + "bias": 0.01412415, + "spread": 0.01324192, + "score": 0.01936079 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00196339, - "spread": 0.00697015, - "score": 0.00724141 + "bias": 0.00125984, + "spread": 0.00546803, + "score": 0.00561129 }, { "profile": "cp_plus_10pct", @@ -3475,9 +3475,9 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.13931809, - "spread": 0.15706694, - "score": 0.20995131 + "bias": -0.13963261, + "spread": 0.1636088, + "score": 0.21509325 }, { "profile": "cp_plus_10pct", @@ -3511,252 +3511,252 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.0015844, - "spread": 0.01735778, - "score": 0.01742994 + "bias": 0.01270562, + "spread": 0.01711929, + "score": 0.02131907 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00178431, - "spread": 0.00855707, - "score": 0.00874112 + "bias": 0.001546, + "spread": 0.00956083, + "score": 0.00968502 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00319845, - "spread": 0.00694423, - "score": 0.00764542 + "bias": -0.00279469, + "spread": 0.00828944, + "score": 0.00874787 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "overall", "condition_bin": "overall", - "bias": 0.00429022, - "spread": 0.00199034, - "score": 0.00472943 + "bias": 0.00506748, + "spread": 0.00358755, + "score": 0.00620885 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00328689, - "spread": 0.01603097, - "score": 0.01636447 + "bias": 0.00581636, + "spread": 0.02031785, + "score": 0.02113398 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00479896, - "spread": 0.00583736, - "score": 0.00755677 + "bias": -0.00216106, + "spread": 0.00619049, + "score": 0.00655686 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00367001, - "spread": 0.00704511, - "score": 0.00794372 + "bias": 0.00423615, + "spread": 0.00670918, + "score": 0.00793462 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00214068, - "spread": 0.00213288, - "score": 0.00302187 + "bias": 0.00293037, + "spread": 0.00075394, + "score": 0.0030258 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00033009, - "spread": 0.00977297, - "score": 0.00977854 + "bias": 0.00333967, + "spread": 0.00789522, + "score": 0.0085725 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00480699, - "spread": 0.00803402, - "score": 0.00936229 + "bias": 0.00588758, + "spread": 0.00546329, + "score": 0.00803188 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.02733043, - "spread": 0.00040764, - "score": 0.02733347 + "bias": -0.02915185, + "spread": 0.00013301, + "score": 0.02915216 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00255402, - "spread": 0.01005698, - "score": 0.01037621 + "bias": -0.00408827, + "spread": 0.0131644, + "score": 0.0137846 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00553739, - "spread": 0.00253473, - "score": 0.00608995 + "bias": 0.00595464, + "spread": 0.00322879, + "score": 0.00677368 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00212832, - "spread": 0.00171646, - "score": 0.00273422 + "bias": 0.00388069, + "spread": 0.0038021, + "score": 0.00543284 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00529421, - "spread": 0.00708544, - "score": 0.00884489 + "bias": 0.00452448, + "spread": 0.00799213, + "score": 0.00918395 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00999186, - "spread": 0.00875742, - "score": 0.01328644 + "bias": 0.00517238, + "spread": 0.01114443, + "score": 0.01228624 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.00995044, - "spread": 0.01508466, - "score": 0.01807092 + "bias": 0.00519258, + "spread": 0.01790259, + "score": 0.01864043 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.07519873, - "spread": 0.08392378, - "score": 0.11268562 + "bias": 0.06872354, + "spread": 0.0653807, + "score": 0.09485547 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.17891073, - "spread": 0.14836137, - "score": 0.23242234 + "bias": 0.15016613, + "spread": 0.15065935, + "score": 0.21271603 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.02649062, - "spread": 0.50542914, - "score": 0.50612288 + "bias": -0.04644301, + "spread": 0.45486429, + "score": 0.45722913 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.1114352, - "spread": 0.15069909, - "score": 0.1874247 + "bias": -0.1063249, + "spread": 0.12035528, + "score": 0.16059383 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00139034, - "spread": 0.00330379, - "score": 0.00358442 + "bias": -0.00077323, + "spread": 0.00576703, + "score": 0.00581864 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.0092899, - "spread": 0.00407708, - "score": 0.01014519 + "bias": 0.00999437, + "spread": 0.00594275, + "score": 0.01162772 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00794191, - "spread": 0.004687, - "score": 0.00922182 + "bias": 0.00975397, + "spread": 0.00642852, + "score": 0.01168186 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00428739, - "spread": 0.00396775, - "score": 0.00584164 + "bias": 0.00566356, + "spread": 0.0046995, + "score": 0.00735943 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": -9.463e-05, - "spread": 0.00343065, - "score": 0.00343196 + "bias": -0.00020249, + "spread": 0.00327978, + "score": 0.00328603 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.17605222, - "spread": 0.11858626, - "score": 0.21226654 + "bias": -0.17729737, + "spread": 0.13427846, + "score": 0.22240743 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": -0.00512954, - "spread": 0.00534205, - "score": 0.00740605 + "bias": -0.00457043, + "spread": 0.00565076, + "score": 0.00726773 }, { "profile": "cp_plus_10pct", @@ -3781,252 +3781,252 @@ "campaign_months": 6, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00985822, - "spread": 0.00956423, - "score": 0.01373532 + "bias": 0.01285433, + "spread": 0.0109598, + "score": 0.01689233 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00374436, - "spread": 0.00423361, - "score": 0.00565187 + "bias": 0.00605458, + "spread": 0.00322079, + "score": 0.00685795 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00267045, - "spread": 0.00663471, - "score": 0.00715197 + "bias": 0.0033417, + "spread": 0.00578487, + "score": 0.00668069 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "overall", "condition_bin": "overall", - "bias": 0.00094456, - "spread": 0.00354445, - "score": 0.00366815 + "bias": 0.00127078, + "spread": 0.00226891, + "score": 0.00260055 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.00450818, - "spread": 0.004753, - "score": 0.00655093 + "bias": -0.01234177, + "spread": 0.00811759, + "score": 0.01477209 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00494676, - "spread": 0.00821588, - "score": 0.00959015 + "bias": -0.00355873, + "spread": 0.00506427, + "score": 0.00618962 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": -0.00017879, - "spread": 0.00120204, - "score": 0.00121527 + "bias": 0.00098761, + "spread": 0.00129091, + "score": 0.00162537 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.0006107, - "spread": 0.00038773, - "score": 0.00072338 + "bias": 0.00113646, + "spread": 0.00113754, + "score": 0.00160795 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00157557, - "spread": 0.00744062, - "score": 0.0076056 + "bias": -0.00024747, + "spread": 0.00391635, + "score": 0.00392416 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00026171, - "spread": 0.00736157, - "score": 0.00736622 + "bias": 0.00190756, + "spread": 0.00535614, + "score": 0.00568568 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.00971409, - "spread": 0.04841127, - "score": 0.04937625 + "bias": 0.0100403, + "spread": 0.04978775, + "score": 0.05079004 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00670118, - "spread": 0.00167178, - "score": 0.00690657 + "bias": -0.00706863, + "spread": 0.00401045, + "score": 0.00812707 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00065821, - "spread": 0.00255107, - "score": 0.00263461 + "bias": 0.00059605, + "spread": 0.00184448, + "score": 0.00193839 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00045186, - "spread": 0.0042961, - "score": 0.0043198 + "bias": 0.00179822, + "spread": 0.0022898, + "score": 0.00291149 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00204379, - "spread": 0.00528983, - "score": 0.00567093 + "bias": 0.00082277, + "spread": 0.00521305, + "score": 0.00527758 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00714932, - "spread": 0.00759208, - "score": 0.01042844 + "bias": 0.00300517, + "spread": 0.00513675, + "score": 0.00595124 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00302996, - "spread": 0.01528507, - "score": 0.01558249 + "bias": -0.0052769, + "spread": 0.01321017, + "score": 0.01422513 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.05127752, - "spread": 0.0523248, - "score": 0.07326165 + "bias": 0.03856704, + "spread": 0.04329231, + "score": 0.05797966 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.41272246, - "spread": 0.46218289, - "score": 0.61963929 + "bias": 0.33013706, + "spread": 0.39713201, + "score": 0.51643423 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.78581652, - "spread": 0.81202171, - "score": 1.12999419 + "bias": 0.71978772, + "spread": 0.712308, + "score": 1.01265841 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.12688772, - "spread": 0.11616809, - "score": 0.17203348 + "bias": -0.0613322, + "spread": 0.09565285, + "score": 0.11362705 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00265643, - "spread": 0.00414965, - "score": 0.00492709 + "bias": -0.0020474, + "spread": 0.00356129, + "score": 0.00410787 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00525397, - "spread": 0.00233223, - "score": 0.00574835 + "bias": 0.00424954, + "spread": 0.00170739, + "score": 0.00457972 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00326977, - "spread": 0.00239038, - "score": 0.00405034 + "bias": 0.00324782, + "spread": 0.00256317, + "score": 0.00413741 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00114216, - "spread": 0.00096137, - "score": 0.0014929 + "bias": 0.00128404, + "spread": 0.00187285, + "score": 0.00227075 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 0.00048834, - "spread": 0.00178031, - "score": 0.00184607 + "bias": 0.00083807, + "spread": 0.00135952, + "score": 0.00159708 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.17628527, - "spread": 0.11198741, - "score": 0.20884845 + "bias": -0.1818521, + "spread": 0.12533315, + "score": 0.22085874 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": -0.00195877, - "spread": 0.01052302, - "score": 0.01070377 + "bias": -0.00020469, + "spread": 0.01250605, + "score": 0.01250773 }, { "profile": "cp_plus_10pct", @@ -4051,90 +4051,90 @@ "campaign_months": 12, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00211588, - "spread": 0.00779494, - "score": 0.008077 + "bias": 0.00088988, + "spread": 0.00440276, + "score": 0.00449179 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00126096, - "spread": 0.00711571, - "score": 0.00722657 + "bias": 0.0026273, + "spread": 0.00443556, + "score": 0.00515528 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00036179, - "spread": 0.00767475, - "score": 0.00768327 + "bias": 0.00090258, + "spread": 0.00549544, + "score": 0.00556907 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "overall", "condition_bin": "overall", - "bias": -0.00439269, - "spread": 0.01138657, - "score": 0.01220449 + "bias": -0.00287756, + "spread": 0.00456619, + "score": 0.00539726 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.01871322, - "spread": 0.03402892, - "score": 0.03883494 + "bias": -0.03168537, + "spread": 0.039835, + "score": 0.0508998 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00110039, - "spread": 0.02579568, - "score": 0.02581914 + "bias": 0.00084732, + "spread": 0.01417803, + "score": 0.01420332 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.01603954, - "spread": 0.02220126, - "score": 0.0273891 + "bias": 0.0162602, + "spread": 0.01471686, + "score": 0.02193126 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00423264, - "spread": 0.00330017, - "score": 0.00536715 + "bias": 0.00111113, + "spread": 0.00225911, + "score": 0.00251758 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.0270099, - "spread": 0.02728048, - "score": 0.03838957 + "bias": -0.01887244, + "spread": 0.0162098, + "score": 0.02487824 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00886402, - "spread": 0.01041873, - "score": 0.01367921 + "bias": -0.00771246, + "spread": 0.01206756, + "score": 0.01432159 }, { "profile": "cp_plus_3pct", @@ -4150,126 +4150,126 @@ "campaign_months": 1, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.01190109, - "spread": 0.01641543, - "score": 0.02027566 + "bias": -0.00823433, + "spread": 0.01419345, + "score": 0.01640909 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00949785, - "spread": 0.01497328, - "score": 0.01773157 + "bias": -0.00878702, + "spread": 0.0114725, + "score": 0.01445095 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00504978, - "spread": 0.00906777, - "score": 0.01037905 + "bias": -0.00401697, + "spread": 0.00546039, + "score": 0.00677878 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00022294, - "spread": 0.02047476, - "score": 0.02047597 + "bias": 0.00030338, + "spread": 0.00795741, + "score": 0.00796319 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01062733, - "spread": 0.04100332, - "score": 0.04235814 + "bias": 0.01961138, + "spread": 0.02304483, + "score": 0.03026005 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.02997567, - "spread": 0.01458315, - "score": 0.03333481 + "bias": 0.03244712, + "spread": 0.03139671, + "score": 0.04515052 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.35172582, - "spread": 0.68430158, - "score": 0.76940218 + "bias": 0.32165068, + "spread": 0.68051288, + "score": 0.75269977 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.04421406, - "spread": 0.05556885, - "score": 0.07101254 + "bias": 0.01454459, + "spread": 0.01834925, + "score": 0.02341452 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.02506632, - "spread": 0.03559409, - "score": 0.04353458 + "bias": -0.0235512, + "spread": 0.03083542, + "score": 0.03880054 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.01092867, - "spread": 0.09237246, - "score": 0.0930167 + "bias": -0.0028911, + "spread": 0.0830519, + "score": 0.0831022 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00244911, - "spread": 0.01846087, - "score": 0.01862261 + "bias": 0.00155688, + "spread": 0.01264384, + "score": 0.01273933 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.01046245, - "spread": 0.02072387, - "score": 0.02321511 + "bias": 0.00708733, + "spread": 0.01811885, + "score": 0.01945567 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00439248, - "spread": 0.0102322, - "score": 0.01113516 + "bias": 0.00414581, + "spread": 0.00760167, + "score": 0.0086587 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00672058, - "spread": 0.01173258, - "score": 0.01352108 + "bias": 0.00410738, + "spread": 0.00720643, + "score": 0.00829477 }, { "profile": "cp_plus_3pct", @@ -4285,9 +4285,9 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.16921406, - "spread": 0.16596524, - "score": 0.23701869 + "bias": -0.16197503, + "spread": 0.17129147, + "score": 0.23574707 }, { "profile": "cp_plus_3pct", @@ -4321,225 +4321,225 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00856047, - "spread": 0.01611924, - "score": 0.01825134 + "bias": -0.00790191, + "spread": 0.02073577, + "score": 0.02219037 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00379671, - "spread": 0.01330774, - "score": 0.01383874 + "bias": -0.00141814, + "spread": 0.01332449, + "score": 0.01339975 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.01503328, - "spread": 0.01800518, - "score": 0.02345605 + "bias": -0.01123048, + "spread": 0.00968632, + "score": 0.01483066 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "overall", "condition_bin": "overall", - "bias": -9.481e-05, - "spread": 0.00198906, - "score": 0.00199132 + "bias": 0.00302244, + "spread": 0.00476053, + "score": 0.00563895 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.0223914, - "spread": 0.01363076, - "score": 0.02621397 + "bias": -0.02052678, + "spread": 0.02752992, + "score": 0.03434014 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00615587, - "spread": 0.01029231, - "score": 0.01199276 + "bias": 0.0024157, + "spread": 0.00750556, + "score": 0.00788473 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.0065776, - "spread": 0.00795394, - "score": 0.01032133 + "bias": 0.00483516, + "spread": 0.0083181, + "score": 0.00962131 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00150887, - "spread": 0.00284859, - "score": 0.00322353 + "bias": -1.31e-06, + "spread": 0.00367032, + "score": 0.00367032 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00349704, - "spread": 0.00391017, - "score": 0.00524583 + "bias": -0.00057763, + "spread": 0.0112109, + "score": 0.01122577 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00352724, - "spread": 0.0040454, - "score": 0.00536719 + "bias": 0.00768561, + "spread": 0.00608034, + "score": 0.00979996 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.00563693, + "bias": -0.00291902, "spread": 0.0, - "score": 0.00563693 + "score": 0.00291902 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00403418, - "spread": 0.00482131, - "score": 0.00628646 + "bias": 0.00057707, + "spread": 0.00543313, + "score": 0.00546369 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00299665, - "spread": 0.00280111, - "score": 0.00410196 + "bias": 0.00679999, + "spread": 0.00567847, + "score": 0.00885917 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00223153, - "spread": 0.00257462, - "score": 0.00340711 + "bias": 0.00098195, + "spread": 0.0029462, + "score": 0.00310553 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -7.599e-05, - "spread": 0.00644377, - "score": 0.00644422 + "bias": 0.00127958, + "spread": 0.00652398, + "score": 0.00664828 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00438893, - "spread": 0.01486293, - "score": 0.0154974 + "bias": -9.06e-06, + "spread": 0.01657582, + "score": 0.01657582 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00741036, - "spread": 0.03565876, - "score": 0.03642061 + "bias": -0.00743411, + "spread": 0.026929, + "score": 0.02793631 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.0681238, - "spread": 0.15929001, - "score": 0.17324595 + "bias": 0.08683135, + "spread": 0.19382762, + "score": 0.21238839 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.53615497, - "spread": 1.20532024, - "score": 1.31918878 + "bias": 0.8130347, + "spread": 1.70999613, + "score": 1.89343925 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.24808964, - "spread": 0.54010193, - "score": 0.59435559 + "bias": -0.27195397, + "spread": 0.58063269, + "score": 0.64116557 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.03227703, - "spread": 0.13394449, - "score": 0.13777857 + "bias": 0.54174056, + "spread": 1.07939748, + "score": 1.20771758 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00223178, - "spread": 0.00847151, - "score": 0.00876055 + "bias": 0.00516831, + "spread": 0.00728436, + "score": 0.00893159 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00448531, - "spread": 0.00422648, - "score": 0.00616288 + "bias": 0.00715352, + "spread": 0.00438398, + "score": 0.00839 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00903769, - "spread": 0.00864944, - "score": 0.0125097 + "bias": 0.0101612, + "spread": 0.0069767, + "score": 0.01232576 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00175427, - "spread": 0.00309921, - "score": 0.00356126 + "bias": 0.0022473, + "spread": 0.00395316, + "score": 0.00454729 }, { "profile": "cp_plus_3pct", @@ -4555,9 +4555,9 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.08268063, - "spread": 0.10667318, - "score": 0.1349639 + "bias": -0.10242597, + "spread": 0.09895429, + "score": 0.1424185 }, { "profile": "cp_plus_3pct", @@ -4591,225 +4591,225 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.0097422, - "spread": 0.01365506, - "score": 0.01677413 + "bias": -0.00056411, + "spread": 0.01760098, + "score": 0.01761002 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00149624, - "spread": 0.0024431, - "score": 0.00286487 + "bias": 0.00304663, + "spread": 0.00739872, + "score": 0.00800143 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00508158, - "spread": 0.00542516, - "score": 0.00743336 + "bias": -0.00045054, + "spread": 0.00725679, + "score": 0.00727076 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "overall", "condition_bin": "overall", - "bias": 0.00207983, - "spread": 0.00191886, - "score": 0.00282979 + "bias": 0.00228401, + "spread": 0.00198226, + "score": 0.00302425 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00875119, - "spread": 0.01322455, - "score": 0.01585787 + "bias": 0.00864832, + "spread": 0.01147377, + "score": 0.01436805 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00030999, - "spread": 0.01159339, - "score": 0.01159754 + "bias": 0.00222142, + "spread": 0.01392474, + "score": 0.01410082 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00611598, - "spread": 0.01153775, - "score": 0.01305852 + "bias": -0.00031231, + "spread": 0.00912442, + "score": 0.00912977 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00032244, - "spread": 0.00318224, - "score": 0.00319853 + "bias": 0.00026807, + "spread": 0.00602349, + "score": 0.00602945 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00782541, - "spread": 0.01506843, - "score": 0.01697924 + "bias": -0.000634, + "spread": 0.01301213, + "score": 0.01302757 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00534537, - "spread": 0.00969039, - "score": 0.01106692 + "bias": 0.00477385, + "spread": 0.00670628, + "score": 0.00823188 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.00999333, - "spread": 0.01491879, - "score": 0.01795653 + "bias": 0.00657013, + "spread": 0.01482025, + "score": 0.01621131 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00743207, - "spread": 0.01301775, - "score": 0.01498992 + "bias": -0.00819585, + "spread": 0.01430886, + "score": 0.01648986 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00527989, - "spread": 0.00412647, - "score": 0.00670111 + "bias": 0.00516737, + "spread": 0.00319774, + "score": 0.00607678 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00074087, - "spread": 0.00077018, - "score": 0.00106868 + "bias": -0.00104128, + "spread": 0.00434843, + "score": 0.00447136 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00048262, - "spread": 0.00986616, - "score": 0.00987796 + "bias": 0.00049525, + "spread": 0.01064762, + "score": 0.01065913 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00382774, - "spread": 0.01998095, - "score": 0.02034429 + "bias": 0.00445221, + "spread": 0.02064797, + "score": 0.02112252 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.02769888, - "spread": 0.04748423, - "score": 0.05497254 + "bias": -0.01552309, + "spread": 0.04705259, + "score": 0.04954708 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.08332139, - "spread": 0.10152118, - "score": 0.13133547 + "bias": 0.09176944, + "spread": 0.10545744, + "score": 0.13979592 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.21697915, - "spread": 0.38680353, - "score": 0.44350527 + "bias": 2.56997081, + "spread": 4.18085305, + "score": 4.90757396 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.01637192, - "spread": 0.14071456, - "score": 0.14166378 + "bias": 0.00186051, + "spread": 0.11099434, + "score": 0.11100993 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.14686144, - "spread": 0.19041793, - "score": 0.24047301 + "bias": -0.15305431, + "spread": 0.05962809, + "score": 0.16425934 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00152228, - "spread": 0.00671371, - "score": 0.00688413 + "bias": -0.00085937, + "spread": 0.00634543, + "score": 0.00640335 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00764496, - "spread": 0.00905634, - "score": 0.0118517 + "bias": 0.00915171, + "spread": 0.01101177, + "score": 0.01431827 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.01081997, - "spread": 0.0122196, - "score": 0.01632147 + "bias": 0.01106678, + "spread": 0.01290517, + "score": 0.0170005 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.0017901, - "spread": 0.00491269, - "score": 0.00522867 + "bias": 0.00021691, + "spread": 0.00448521, + "score": 0.00449045 }, { "profile": "cp_plus_3pct", @@ -4825,9 +4825,9 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.1067554, - "spread": 0.1296244, - "score": 0.16792618 + "bias": -0.11568578, + "spread": 0.12923753, + "score": 0.17345183 }, { "profile": "cp_plus_3pct", @@ -4861,252 +4861,252 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00069284, - "spread": 0.01593002, - "score": 0.01594508 + "bias": 0.01045417, + "spread": 0.01661791, + "score": 0.01963274 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00267275, - "spread": 0.0083816, - "score": 0.00879743 + "bias": 0.00042274, + "spread": 0.00969151, + "score": 0.00970073 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00180599, - "spread": 0.00688135, - "score": 0.00711439 + "bias": -0.00338729, + "spread": 0.00745877, + "score": 0.00819188 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "overall", "condition_bin": "overall", - "bias": 0.00416586, - "spread": 0.00190254, - "score": 0.00457975 + "bias": 0.0050245, + "spread": 0.00334348, + "score": 0.00603527 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00463286, - "spread": 0.01513756, - "score": 0.01583064 + "bias": 0.00789237, + "spread": 0.01788963, + "score": 0.01955322 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00059253, - "spread": 0.00321396, - "score": 0.00326813 + "bias": 0.00094777, + "spread": 0.0043819, + "score": 0.00448322 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00548146, - "spread": 0.00602771, - "score": 0.00814738 + "bias": 0.00531738, + "spread": 0.00699775, + "score": 0.00878881 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00099907, - "spread": 0.00168242, - "score": 0.0019567 + "bias": 0.00209374, + "spread": 0.00145054, + "score": 0.00254711 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00251916, - "spread": 0.00922088, - "score": 0.00955881 + "bias": 0.00547259, + "spread": 0.00728442, + "score": 0.00911109 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00590752, - "spread": 0.0079081, - "score": 0.00987101 + "bias": 0.00752258, + "spread": 0.00472013, + "score": 0.00888082 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.00498765, - "spread": 0.00139027, - "score": 0.00517779 + "bias": -0.00678479, + "spread": 0.0013484, + "score": 0.00691748 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00152451, - "spread": 0.00906667, - "score": 0.00919395 + "bias": -0.00218323, + "spread": 0.0119833, + "score": 0.01218056 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00415223, - "spread": 0.00166808, - "score": 0.00447477 + "bias": 0.00495827, + "spread": 0.00282461, + "score": 0.00570638 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00210052, - "spread": 0.00192212, - "score": 0.00284723 + "bias": 0.0036632, + "spread": 0.00304039, + "score": 0.00476057 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00685613, - "spread": 0.00653401, - "score": 0.009471 + "bias": 0.00620873, + "spread": 0.00770391, + "score": 0.00989437 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01172617, - "spread": 0.00824519, - "score": 0.01433479 + "bias": 0.00811626, + "spread": 0.00974187, + "score": 0.01267981 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.01161678, - "spread": 0.01389679, - "score": 0.01811272 + "bias": 0.0087824, + "spread": 0.01356382, + "score": 0.01615882 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.07594206, - "spread": 0.07663778, - "score": 0.10789136 + "bias": 0.07323319, + "spread": 0.06196362, + "score": 0.09593013 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.23474407, - "spread": 0.06581682, - "score": 0.24379629 + "bias": 0.21178395, + "spread": 0.04527743, + "score": 0.21656982 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.0718369, - "spread": 0.38219817, - "score": 0.3888907 + "bias": 0.03387787, + "spread": 0.33273666, + "score": 0.33445687 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.08564754, - "spread": 0.14083608, - "score": 0.16483417 + "bias": -0.07200078, + "spread": 0.12829367, + "score": 0.14711688 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00069406, - "spread": 0.00336236, - "score": 0.00343325 + "bias": 0.00100191, + "spread": 0.00488649, + "score": 0.00498815 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00594013, - "spread": 0.00469701, - "score": 0.00757279 + "bias": 0.00694924, + "spread": 0.00638676, + "score": 0.00943836 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00673786, - "spread": 0.00434097, - "score": 0.00801516 + "bias": 0.00841949, + "spread": 0.00661497, + "score": 0.01070727 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00367086, - "spread": 0.00268145, - "score": 0.00454592 + "bias": 0.00519514, + "spread": 0.00421024, + "score": 0.00668697 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 0.00076609, - "spread": 0.00229501, - "score": 0.0024195 + "bias": 0.00077385, + "spread": 0.00230799, + "score": 0.00243427 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.13525274, - "spread": 0.08355387, - "score": 0.15897973 + "bias": -0.13329882, + "spread": 0.09539505, + "score": 0.16391702 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": -0.00162782, - "spread": 0.00157318, - "score": 0.00226378 + "bias": -0.00050686, + "spread": 0.00266896, + "score": 0.00271666 }, { "profile": "cp_plus_3pct", @@ -5131,252 +5131,252 @@ "campaign_months": 6, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00868033, - "spread": 0.00829359, - "score": 0.01200549 + "bias": 0.01249869, + "spread": 0.01051497, + "score": 0.01633346 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00340421, - "spread": 0.00463928, - "score": 0.00575426 + "bias": 0.00593957, + "spread": 0.00291036, + "score": 0.00661427 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00404146, - "spread": 0.00641582, - "score": 0.00758263 + "bias": 0.00435377, + "spread": 0.00557409, + "score": 0.00707289 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "overall", "condition_bin": "overall", - "bias": 0.00095758, - "spread": 0.00340573, - "score": 0.00353779 + "bias": 0.00117751, + "spread": 0.00233121, + "score": 0.00261172 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.00293732, - "spread": 0.004981, - "score": 0.00578258 + "bias": -0.00696962, + "spread": 0.00835328, + "score": 0.01087901 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00103005, - "spread": 0.00648312, - "score": 0.00656444 + "bias": -0.00045695, + "spread": 0.00471098, + "score": 0.00473309 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00088873, - "spread": 0.00084983, - "score": 0.00122965 + "bias": 0.00039042, + "spread": 0.00142789, + "score": 0.00148031 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00016494, - "spread": 0.00034275, - "score": 0.00038037 + "bias": 0.00093384, + "spread": 0.0009153, + "score": 0.0013076 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00018383, - "spread": 0.00718337, - "score": 0.00718572 + "bias": 0.00111181, + "spread": 0.00399198, + "score": 0.00414392 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00177345, - "spread": 0.00756737, - "score": 0.0077724 + "bias": 0.00303401, + "spread": 0.00523119, + "score": 0.00604736 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.00358844, - "spread": 0.01298148, - "score": 0.01346832 + "bias": 0.00380836, + "spread": 0.0140525, + "score": 0.01455941 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00391575, - "spread": 0.00223868, - "score": 0.00451052 + "bias": -0.00428002, + "spread": 0.00298781, + "score": 0.00521973 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00010413, - "spread": 0.00245938, - "score": 0.00246158 + "bias": 0.00040165, + "spread": 0.00206306, + "score": 0.0021018 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00017205, - "spread": 0.00418455, - "score": 0.00418808 + "bias": 0.00113303, + "spread": 0.00239608, + "score": 0.00265046 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00331532, - "spread": 0.00494331, - "score": 0.00595212 + "bias": 0.00167765, + "spread": 0.00502789, + "score": 0.0053004 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00893412, - "spread": 0.00686222, - "score": 0.01126537 + "bias": 0.00469392, + "spread": 0.00545313, + "score": 0.00719511 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.00056676, - "spread": 0.01321297, - "score": 0.01322511 + "bias": -0.00058593, + "spread": 0.01223077, + "score": 0.01224479 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.0530807, - "spread": 0.04638849, - "score": 0.07049434 + "bias": 0.03959807, + "spread": 0.0388758, + "score": 0.05549176 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.38941328, - "spread": 0.4195148, - "score": 0.57239442 + "bias": 0.30938143, + "spread": 0.35540715, + "score": 0.47120178 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.5425848, - "spread": 0.67031901, - "score": 0.8623954 + "bias": 0.47982748, + "spread": 0.56339089, + "score": 0.74002953 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.11606242, - "spread": 0.09257672, - "score": 0.1484619 + "bias": -0.06971382, + "spread": 0.08197825, + "score": 0.1076125 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 3.631e-05, - "spread": 0.00314395, - "score": 0.00314416 + "bias": 0.00029702, + "spread": 0.00260723, + "score": 0.0026241 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00246796, - "spread": 0.00235494, - "score": 0.00341124 + "bias": 0.00215209, + "spread": 0.00187315, + "score": 0.0028531 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00224025, - "spread": 0.00208526, - "score": 0.00306056 + "bias": 0.00236111, + "spread": 0.0021467, + "score": 0.00319111 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00060449, - "spread": 0.00086419, - "score": 0.00105463 + "bias": 0.00072373, + "spread": 0.00128785, + "score": 0.00147727 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 0.00109056, - "spread": 0.00265989, - "score": 0.00287477 + "bias": 0.00104093, + "spread": 0.00247645, + "score": 0.00268632 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.13885579, - "spread": 0.08159688, - "score": 0.16105584 + "bias": -0.14157892, + "spread": 0.09421063, + "score": 0.1700595 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": 0.0009294, - "spread": 0.01197101, - "score": 0.01200703 + "bias": 0.0020736, + "spread": 0.01291786, + "score": 0.01308323 }, { "profile": "cp_plus_3pct", @@ -5401,90 +5401,90 @@ "campaign_months": 12, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00128968, - "spread": 0.00758608, - "score": 0.00769493 + "bias": 0.00108556, + "spread": 0.00475449, + "score": 0.00487684 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00083683, - "spread": 0.00699539, - "score": 0.00704526 + "bias": 0.00168415, + "spread": 0.00490224, + "score": 0.00518346 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.0005884, - "spread": 0.0074146, - "score": 0.00743791 + "bias": 0.00111365, + "spread": 0.00555302, + "score": 0.00566359 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "overall", "condition_bin": "overall", - "bias": -0.00429428, - "spread": 0.01132645, - "score": 0.01211319 + "bias": -0.00317585, + "spread": 0.00504154, + "score": 0.00595845 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.01471574, - "spread": 0.0281923, - "score": 0.03180187 + "bias": -0.02652766, + "spread": 0.0366653, + "score": 0.0452555 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00434809, - "spread": 0.02635468, - "score": 0.02671095 + "bias": 0.00423022, + "spread": 0.01596882, + "score": 0.01651963 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.02553907, - "spread": 0.02618097, - "score": 0.03657441 + "bias": 0.02778981, + "spread": 0.02552138, + "score": 0.03773082 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.01023427, - "spread": 0.02179657, - "score": 0.02407967 + "bias": -0.01273185, + "spread": 0.02057853, + "score": 0.02419867 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.02372059, - "spread": 0.02537939, - "score": 0.03473874 + "bias": -0.0190247, + "spread": 0.01798137, + "score": 0.02617764 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00746727, - "spread": 0.00972005, - "score": 0.01225722 + "bias": -0.00823063, + "spread": 0.01229982, + "score": 0.01479962 }, { "profile": "rated_plus_5pct", @@ -5500,126 +5500,126 @@ "campaign_months": 1, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.01166778, - "spread": 0.00760655, - "score": 0.01392827 + "bias": -0.00979177, + "spread": 0.01210941, + "score": 0.01557294 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00883065, - "spread": 0.01299074, - "score": 0.01570795 + "bias": -0.008768, + "spread": 0.01018689, + "score": 0.01344063 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00533621, - "spread": 0.00988153, - "score": 0.01123031 + "bias": -0.00445015, + "spread": 0.00627437, + "score": 0.0076923 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00137974, - "spread": 0.02048129, - "score": 0.02052771 + "bias": 0.00109642, + "spread": 0.00870086, + "score": 0.00876967 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01268986, - "spread": 0.04054447, - "score": 0.04248396 + "bias": 0.02098653, + "spread": 0.0228251, + "score": 0.03100677 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.03172131, - "spread": 0.01799019, - "score": 0.03646763 + "bias": 0.03096721, + "spread": 0.03572584, + "score": 0.047279 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.35107716, - "spread": 0.66333932, - "score": 0.75051597 + "bias": 0.31355811, + "spread": 0.65398807, + "score": 0.72527173 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.04768047, - "spread": 0.05578494, - "score": 0.0733852 + "bias": 0.01969956, + "spread": 0.01472157, + "score": 0.02459262 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.00947451, - "spread": 0.01380549, - "score": 0.01674389 + "bias": 0.01059294, + "spread": 0.00921471, + "score": 0.01403998 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.00306247, - "spread": 0.08018243, - "score": 0.08024089 + "bias": 0.00325617, + "spread": 0.09159175, + "score": 0.09164961 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.0049816, - "spread": 0.02014672, - "score": 0.02075347 + "bias": 0.0056638, + "spread": 0.01574071, + "score": 0.01672867 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00620599, - "spread": 0.0199424, - "score": 0.02088572 + "bias": 0.00445195, + "spread": 0.02011758, + "score": 0.02060429 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.0192129, - "spread": 0.03165775, - "score": 0.03703173 + "bias": -0.02042729, + "spread": 0.03051291, + "score": 0.03671937 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": -0.02905401, - "spread": 0.03554335, - "score": 0.04590714 + "bias": -0.03112782, + "spread": 0.03195141, + "score": 0.04460755 }, { "profile": "rated_plus_5pct", @@ -5635,9 +5635,9 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.13798073, - "spread": 0.14622772, - "score": 0.20105031 + "bias": -0.13436188, + "spread": 0.14363207, + "score": 0.19668068 }, { "profile": "rated_plus_5pct", @@ -5671,225 +5671,225 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00769592, - "spread": 0.01170343, - "score": 0.01400705 + "bias": -0.0060305, + "spread": 0.02034191, + "score": 0.02121698 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00145086, - "spread": 0.01310795, - "score": 0.013188 + "bias": -0.00033128, + "spread": 0.01484537, + "score": 0.01484906 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.01071466, - "spread": 0.01720786, - "score": 0.02027102 + "bias": -0.00856097, + "spread": 0.01089453, + "score": 0.01385572 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "overall", "condition_bin": "overall", - "bias": -6.683e-05, - "spread": 0.00197798, - "score": 0.00197911 + "bias": 0.00383005, + "spread": 0.0046734, + "score": 0.00604235 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.01775934, - "spread": 0.01081611, - "score": 0.02079381 + "bias": -0.01837636, + "spread": 0.02588659, + "score": 0.03174597 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00040418, - "spread": 0.01076934, - "score": 0.01077692 + "bias": 0.00703366, + "spread": 0.00817287, + "score": 0.01078277 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.01246173, - "spread": 0.00882836, - "score": 0.01527202 + "bias": 0.01233114, + "spread": 0.00936113, + "score": 0.01548185 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00346831, - "spread": 0.00227903, - "score": 0.00415008 + "bias": -0.00167908, + "spread": 0.00283788, + "score": 0.0032974 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00128, - "spread": 0.00628149, - "score": 0.00641058 + "bias": 0.00031642, + "spread": 0.01269877, + "score": 0.01270271 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00279179, - "spread": 0.00579763, - "score": 0.0064348 + "bias": 0.00917796, + "spread": 0.00724169, + "score": 0.0116909 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.01573493, + "bias": 0.01908438, "spread": 0.0, - "score": 0.01573493 + "score": 0.01908438 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00860291, - "spread": 0.0081331, - "score": 0.01183881 + "bias": 0.00769753, + "spread": 0.00806725, + "score": 0.01115045 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00019069, - "spread": 0.00213692, - "score": 0.00214542 + "bias": 0.00519229, + "spread": 0.00434597, + "score": 0.00677107 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00172563, - "spread": 0.00341239, - "score": 0.00382389 + "bias": 0.00223081, + "spread": 0.0031106, + "score": 0.00382783 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00216929, - "spread": 0.00535878, - "score": 0.0057812 + "bias": 0.00360561, + "spread": 0.00793792, + "score": 0.00871843 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00031865, - "spread": 0.01420455, - "score": 0.01420812 + "bias": 0.00428564, + "spread": 0.02046766, + "score": 0.02091152 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.00109011, - "spread": 0.03863048, - "score": 0.03864586 + "bias": -0.00014807, + "spread": 0.02839463, + "score": 0.02839502 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.07244372, - "spread": 0.14583859, - "score": 0.16284037 + "bias": 0.08983448, + "spread": 0.18443263, + "score": 0.20514782 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.48443718, - "spread": 1.08792829, - "score": 1.1909103 + "bias": 0.82932711, + "spread": 1.71814098, + "score": 1.90782387 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.09159499, - "spread": 0.18544505, - "score": 0.20683208 + "bias": -0.10485151, + "spread": 0.20956452, + "score": 0.23433123 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.0818014, - "spread": 0.1102106, - "score": 0.13725103 + "bias": -0.06070144, + "spread": 0.1947615, + "score": 0.20400173 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00537257, - "spread": 0.01077628, - "score": 0.01204129 + "bias": 0.0088125, + "spread": 0.00978613, + "score": 0.01316923 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00264744, - "spread": 0.00536335, - "score": 0.00598118 + "bias": 0.00676373, + "spread": 0.00643298, + "score": 0.00933441 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00948326, - "spread": 0.00918326, - "score": 0.01320092 + "bias": 0.01278762, + "spread": 0.00937273, + "score": 0.0158547 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": -0.03444503, - "spread": 0.02608581, - "score": 0.04320798 + "bias": -0.03352309, + "spread": 0.02768265, + "score": 0.04347559 }, { "profile": "rated_plus_5pct", @@ -5905,9 +5905,9 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.06480771, - "spread": 0.10600892, - "score": 0.12424947 + "bias": -0.08699451, + "spread": 0.09529401, + "score": 0.12903097 }, { "profile": "rated_plus_5pct", @@ -5941,225 +5941,225 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00588838, - "spread": 0.01173422, - "score": 0.01312878 + "bias": 5.89e-05, + "spread": 0.01802571, + "score": 0.01802581 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00284963, - "spread": 0.00395535, - "score": 0.00487495 + "bias": 0.00484604, + "spread": 0.00923728, + "score": 0.01043127 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00236987, - "spread": 0.0052368, - "score": 0.00574807 + "bias": 0.00312919, + "spread": 0.00802938, + "score": 0.00861759 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "overall", "condition_bin": "overall", - "bias": 0.00210247, - "spread": 0.00191523, - "score": 0.00284403 + "bias": 0.00270328, + "spread": 0.00266483, + "score": 0.00379592 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00768078, - "spread": 0.01011721, - "score": 0.01270245 + "bias": 0.00812477, + "spread": 0.01494864, + "score": 0.01701393 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.0068387, - "spread": 0.01087394, - "score": 0.01284564 + "bias": 0.00844995, + "spread": 0.01304172, + "score": 0.01553988 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.01209698, - "spread": 0.01296628, - "score": 0.01773306 + "bias": 0.00494351, + "spread": 0.0092698, + "score": 0.01050559 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00202726, - "spread": 0.00350005, - "score": 0.00404476 + "bias": -0.00147479, + "spread": 0.00764262, + "score": 0.00778361 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00655081, - "spread": 0.01522438, - "score": 0.01657392 + "bias": -0.0014282, + "spread": 0.01214098, + "score": 0.01222469 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00516104, - "spread": 0.00933226, - "score": 0.0106643 + "bias": 0.00618861, + "spread": 0.00581893, + "score": 0.00849464 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.01887459, - "spread": 0.00182764, - "score": 0.01896287 + "bias": 0.01536338, + "spread": 0.00275597, + "score": 0.01560862 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.0087472, - "spread": 0.01415733, - "score": 0.01664162 + "bias": -0.00886857, + "spread": 0.01374086, + "score": 0.01635429 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00260534, - "spread": 0.00387762, - "score": 0.00467159 + "bias": 0.00306326, + "spread": 0.00204868, + "score": 0.00368519 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00030891, - "spread": 0.00173394, - "score": 0.00176124 + "bias": 7.23e-06, + "spread": 0.00389336, + "score": 0.00389337 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00365484, - "spread": 0.00952955, - "score": 0.01020638 + "bias": 0.00380104, + "spread": 0.01196702, + "score": 0.01255617 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00924582, - "spread": 0.01768661, - "score": 0.01995748 + "bias": 0.00797806, + "spread": 0.01996, + "score": 0.02149537 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.02381222, - "spread": 0.0403035, - "score": 0.04681232 + "bias": -0.01395672, + "spread": 0.04347342, + "score": 0.04565882 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.08787257, - "spread": 0.11065362, - "score": 0.14130043 + "bias": 0.08820189, + "spread": 0.09870215, + "score": 0.13236951 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.19576611, - "spread": 0.37626555, - "score": 0.42414636 + "bias": 3.30144175, + "spread": 5.47411351, + "score": 6.39260795 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.01562162, - "spread": 0.05983303, - "score": 0.06183872 + "bias": 0.00052538, + "spread": 0.02975468, + "score": 0.02975932 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.05790435, - "spread": 0.14894849, - "score": 0.15980791 + "bias": -0.13453592, + "spread": 0.09512491, + "score": 0.16476851 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.0052374, - "spread": 0.00956085, - "score": 0.01090138 + "bias": 0.00342817, + "spread": 0.00780622, + "score": 0.00852581 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00439853, - "spread": 0.008882, - "score": 0.00991146 + "bias": 0.00689817, + "spread": 0.01081379, + "score": 0.01282664 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.01105979, - "spread": 0.01141505, - "score": 0.0158941 + "bias": 0.01144775, + "spread": 0.01163292, + "score": 0.01632103 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": -0.0231453, - "spread": 0.02652219, - "score": 0.0352013 + "bias": -0.02374437, + "spread": 0.02586732, + "score": 0.03511287 }, { "profile": "rated_plus_5pct", @@ -6175,9 +6175,9 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.09577589, - "spread": 0.10841197, - "score": 0.14465882 + "bias": -0.10121087, + "spread": 0.12005327, + "score": 0.15702365 }, { "profile": "rated_plus_5pct", @@ -6211,252 +6211,252 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.0029589, - "spread": 0.01682361, - "score": 0.01708183 + "bias": 0.01105393, + "spread": 0.01552398, + "score": 0.01905737 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00094612, - "spread": 0.00976476, - "score": 0.00981049 + "bias": 0.00138345, + "spread": 0.01027632, + "score": 0.01036902 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.0010468, - "spread": 0.00734971, - "score": 0.00742388 + "bias": -0.00015434, + "spread": 0.01038911, + "score": 0.01039026 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "overall", "condition_bin": "overall", - "bias": 0.00419323, - "spread": 0.00188561, - "score": 0.00459769 + "bias": 0.00487468, + "spread": 0.00354673, + "score": 0.00602841 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00705461, - "spread": 0.01536865, - "score": 0.01691044 + "bias": 0.00760595, + "spread": 0.02040947, + "score": 0.02178066 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.0065577, - "spread": 0.00186177, - "score": 0.00681686 + "bias": 0.00573236, + "spread": 0.00493097, + "score": 0.00756138 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.01040807, - "spread": 0.0070964, - "score": 0.0125971 + "bias": 0.00818592, + "spread": 0.00732307, + "score": 0.01098347 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00077275, - "spread": 0.00194535, - "score": 0.00209321 + "bias": 0.00149867, + "spread": 0.00228956, + "score": 0.00273643 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00416173, - "spread": 0.00918967, - "score": 0.01008811 + "bias": 0.00571729, + "spread": 0.00724766, + "score": 0.00923125 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00580073, - "spread": 0.00768208, - "score": 0.00962616 + "bias": 0.00742511, + "spread": 0.00490831, + "score": 0.00890077 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.0197497, - "spread": 0.00243129, - "score": 0.01989879 + "bias": 0.01777008, + "spread": 0.00288778, + "score": 0.01800319 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00039165, - "spread": 0.00756584, - "score": 0.00757597 + "bias": -0.00074557, + "spread": 0.01065746, + "score": 0.0106835 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.0024974, - "spread": 0.00122736, - "score": 0.0027827 + "bias": 0.0037579, + "spread": 0.00244931, + "score": 0.00448564 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00206803, - "spread": 0.00224207, - "score": 0.00305019 + "bias": 0.00352845, + "spread": 0.00285101, + "score": 0.00453632 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00977683, - "spread": 0.00605569, - "score": 0.01150034 + "bias": 0.00736004, + "spread": 0.007947, + "score": 0.01083167 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01473194, - "spread": 0.00675888, - "score": 0.01620841 + "bias": 0.00908025, + "spread": 0.01155892, + "score": 0.01469896 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.01361913, - "spread": 0.01496244, - "score": 0.02023253 + "bias": 0.00998937, + "spread": 0.0150214, + "score": 0.01803968 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.08050641, - "spread": 0.07506602, - "score": 0.11007356 + "bias": 0.0737964, + "spread": 0.0558678, + "score": 0.09255874 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.19710426, - "spread": 0.11207981, - "score": 0.22674209 + "bias": 0.17867893, + "spread": 0.10197392, + "score": 0.20573002 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.02683948, - "spread": 0.36925156, - "score": 0.3702257 + "bias": -0.0004076, + "spread": 0.27692085, + "score": 0.27692115 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.08627481, - "spread": 0.17586774, - "score": 0.19588978 + "bias": -0.03422105, + "spread": 0.18300165, + "score": 0.1861738 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00387624, - "spread": 0.00393354, - "score": 0.0055225 + "bias": 0.00383434, + "spread": 0.00489705, + "score": 0.00621959 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00237809, - "spread": 0.00562188, - "score": 0.00610416 + "bias": 0.00439849, + "spread": 0.00710613, + "score": 0.00835726 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00554474, - "spread": 0.00463738, - "score": 0.00722837 + "bias": 0.0083034, + "spread": 0.00747767, + "score": 0.01117416 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00290452, - "spread": 0.00142409, - "score": 0.00323485 + "bias": 0.00498331, + "spread": 0.00457844, + "score": 0.00676724 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": -0.03526341, - "spread": 0.02342437, - "score": 0.04233449 + "bias": -0.03533749, + "spread": 0.02329616, + "score": 0.04232552 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.11452406, - "spread": 0.0698213, - "score": 0.13412969 + "bias": -0.11615944, + "spread": 0.08368342, + "score": 0.143164 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": -0.03435083, - "spread": 0.02181242, - "score": 0.04069105 + "bias": -0.03301646, + "spread": 0.02410985, + "score": 0.04088241 }, { "profile": "rated_plus_5pct", @@ -6481,252 +6481,252 @@ "campaign_months": 6, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00964312, - "spread": 0.00865435, - "score": 0.01295714 + "bias": 0.01151458, + "spread": 0.01138407, + "score": 0.01619205 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.0035372, - "spread": 0.00524403, - "score": 0.00632548 + "bias": 0.00554394, + "spread": 0.00308815, + "score": 0.00634602 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00531529, - "spread": 0.00642221, - "score": 0.00833649 + "bias": 0.00515227, + "spread": 0.00646274, + "score": 0.00826516 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "overall", "condition_bin": "overall", - "bias": 0.00099684, - "spread": 0.00341025, - "score": 0.00355295 + "bias": 0.00132686, + "spread": 0.00199475, + "score": 0.00239574 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.00061921, - "spread": 0.00510911, - "score": 0.0051465 + "bias": -0.0060163, + "spread": 0.0072038, + "score": 0.00938566 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00390236, - "spread": 0.0062922, - "score": 0.00740407 + "bias": 0.00344043, + "spread": 0.0045938, + "score": 0.0057393 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00466426, - "spread": 0.00119937, - "score": 0.00481599 + "bias": 0.00265895, + "spread": 0.00071774, + "score": 0.00275412 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00116816, - "spread": 0.00039379, - "score": 0.00123275 + "bias": 0.00037415, + "spread": 0.00116786, + "score": 0.00122633 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00139308, - "spread": 0.00727222, - "score": 0.00740445 + "bias": 0.00274479, + "spread": 0.00345049, + "score": 0.00440905 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00272843, - "spread": 0.0070663, - "score": 0.00757475 + "bias": 0.00347671, + "spread": 0.00502, + "score": 0.00610638 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.02100137, - "spread": 0.00269335, - "score": 0.02117337 + "bias": 0.02133138, + "spread": 0.00135312, + "score": 0.02137426 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00087444, - "spread": 0.00443805, - "score": 0.00452338 + "bias": -0.00130748, + "spread": 0.00239537, + "score": 0.00272897 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00045335, - "spread": 0.00256036, - "score": 0.00260019 + "bias": 0.00032265, + "spread": 0.00209835, + "score": 0.00212301 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00031697, - "spread": 0.00419068, - "score": 0.00420265 + "bias": 0.00080192, + "spread": 0.00207644, + "score": 0.00222591 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00508659, - "spread": 0.00457293, - "score": 0.00683996 + "bias": 0.0027556, + "spread": 0.0045723, + "score": 0.00533847 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.0115283, - "spread": 0.00640595, - "score": 0.01318855 + "bias": 0.00650074, + "spread": 0.00518124, + "score": 0.00831293 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.00319144, - "spread": 0.01276425, - "score": 0.01315717 + "bias": 0.00180327, + "spread": 0.01274965, + "score": 0.01287655 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.05529595, - "spread": 0.04671727, - "score": 0.07238885 + "bias": 0.04358792, + "spread": 0.03610154, + "score": 0.05659707 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.37350333, - "spread": 0.38555721, - "score": 0.53680453 + "bias": 0.30348228, + "spread": 0.33129868, + "score": 0.44928867 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.44598939, - "spread": 0.68459115, - "score": 0.81705053 + "bias": 0.39506923, + "spread": 0.57990545, + "score": 0.70169084 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.11002322, - "spread": 0.10217321, - "score": 0.15014817 + "bias": -0.06793659, + "spread": 0.08630948, + "score": 0.10983946 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00301787, - "spread": 0.00238611, - "score": 0.00384722 + "bias": 0.00305222, + "spread": 0.00153847, + "score": 0.00341803 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": -0.00042106, - "spread": 0.00275645, - "score": 0.00278842 + "bias": 1.171e-05, + "spread": 0.00256482, + "score": 0.00256484 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00102344, - "spread": 0.00196839, - "score": 0.00221855 + "bias": 0.00175859, + "spread": 0.00216604, + "score": 0.00279004 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": -0.00017397, - "spread": 0.00125644, - "score": 0.00126843 + "bias": 0.0005115, + "spread": 0.00157738, + "score": 0.00165823 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 0.0014506, - "spread": 0.00423985, - "score": 0.00448113 + "bias": 0.00207495, + "spread": 0.00404066, + "score": 0.00454229 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.12426875, - "spread": 0.07054484, - "score": 0.1428961 + "bias": -0.12692071, + "spread": 0.07901331, + "score": 0.14950575 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": -0.0080647, - "spread": 0.02570926, - "score": 0.02694449 + "bias": -0.0065305, + "spread": 0.02663003, + "score": 0.02741908 }, { "profile": "rated_plus_5pct", @@ -6751,90 +6751,90 @@ "campaign_months": 12, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00172505, - "spread": 0.00786196, - "score": 0.00804898 + "bias": 0.00103324, + "spread": 0.00430485, + "score": 0.00442711 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00070242, - "spread": 0.00733085, - "score": 0.00736443 + "bias": 0.00154745, + "spread": 0.00488782, + "score": 0.00512692 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00164808, - "spread": 0.00751702, - "score": 0.00769556 + "bias": 0.00181074, + "spread": 0.0057884, + "score": 0.00606501 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "overall", "condition_bin": "overall", - "bias": -0.00449064, - "spread": 0.01159994, - "score": 0.01243883 + "bias": -0.00241087, + "spread": 0.00460011, + "score": 0.00519358 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.0133029, - "spread": 0.03217985, - "score": 0.03482111 + "bias": -0.02907739, + "spread": 0.05095303, + "score": 0.05866605 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00154385, - "spread": 0.02629813, - "score": 0.02634341 + "bias": -0.00054233, + "spread": 0.01142728, + "score": 0.01144014 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.01201269, - "spread": 0.02166848, - "score": 0.02477555 + "bias": 0.01444568, + "spread": 0.01956873, + "score": 0.02432309 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00419692, - "spread": 0.00421738, - "score": 0.00594983 + "bias": 0.00186463, + "spread": 0.00386582, + "score": 0.00429201 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.02766271, - "spread": 0.02926485, - "score": 0.0402698 + "bias": -0.02199548, + "spread": 0.01607326, + "score": 0.02724244 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00698286, - "spread": 0.00856445, - "score": 0.01105035 + "bias": -0.00554469, + "spread": 0.0125221, + "score": 0.01369477 }, { "profile": "ti_dependent_cp", @@ -6850,126 +6850,126 @@ "campaign_months": 1, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.02928841, - "spread": 0.03298634, - "score": 0.04411247 + "bias": -0.02513854, + "spread": 0.0290483, + "score": 0.03841549 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.01483987, - "spread": 0.01831952, - "score": 0.02357597 + "bias": -0.01252308, + "spread": 0.0145112, + "score": 0.01916774 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00614511, - "spread": 0.00938917, - "score": 0.01122136 + "bias": -0.00422, + "spread": 0.00601121, + "score": 0.0073446 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00590934, - "spread": 0.02141461, - "score": 0.02221499 + "bias": 0.00486601, + "spread": 0.0063733, + "score": 0.00801854 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.0243693, - "spread": 0.04178972, - "score": 0.04837606 + "bias": 0.03207558, + "spread": 0.01912137, + "score": 0.0373426 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.05301411, - "spread": 0.01568386, - "score": 0.05528544 + "bias": 0.05416586, + "spread": 0.03823935, + "score": 0.06630375 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.37443452, - "spread": 0.6671997, - "score": 0.76508604 + "bias": 0.38060479, + "spread": 0.74086912, + "score": 0.8329148 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.07572818, - "spread": 0.0509734, - "score": 0.09128551 + "bias": 0.04733376, + "spread": 0.00644466, + "score": 0.04777048 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.03926698, - "spread": 0.01300946, - "score": 0.04136595 + "bias": 0.04134675, + "spread": 0.00609728, + "score": 0.04179391 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.041602, - "spread": 0.08268889, - "score": 0.09256446 + "bias": -0.05482917, + "spread": 0.07903628, + "score": 0.09619237 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00018104, - "spread": 0.01674569, - "score": 0.01674667 + "bias": 0.00084197, + "spread": 0.01221115, + "score": 0.01224014 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.01060232, - "spread": 0.01970981, - "score": 0.02238048 + "bias": 0.0089842, + "spread": 0.01879869, + "score": 0.02083522 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00449421, - "spread": 0.01026217, - "score": 0.01120313 + "bias": 0.00459659, + "spread": 0.00863466, + "score": 0.00978192 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00654954, - "spread": 0.01149304, - "score": 0.01322824 + "bias": 0.00443938, + "spread": 0.00783821, + "score": 0.00900809 }, { "profile": "ti_dependent_cp", @@ -6985,9 +6985,9 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.15918519, - "spread": 0.17707135, - "score": 0.23810541 + "bias": -0.16479213, + "spread": 0.17286989, + "score": 0.23883141 }, { "profile": "ti_dependent_cp", @@ -7021,225 +7021,225 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00460095, - "spread": 0.01428279, - "score": 0.01500556 + "bias": -0.00619845, + "spread": 0.02701641, + "score": 0.02771836 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00243233, - "spread": 0.01274009, - "score": 0.0129702 + "bias": -0.00139266, + "spread": 0.01396787, + "score": 0.01403713 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.01547368, - "spread": 0.01800735, - "score": 0.02374235 + "bias": -0.01099904, + "spread": 0.00903603, + "score": 0.01423478 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "overall", "condition_bin": "overall", - "bias": -9.79e-05, - "spread": 0.00204292, - "score": 0.00204526 + "bias": 0.00426361, + "spread": 0.00492041, + "score": 0.00651067 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.01901503, - "spread": 0.01177995, - "score": 0.02236825 + "bias": -0.01847559, + "spread": 0.02166717, + "score": 0.02847479 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00948094, - "spread": 0.00930557, - "score": 0.01328465 + "bias": 0.00164046, + "spread": 0.00900754, + "score": 0.0091557 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00355395, - "spread": 0.00713565, - "score": 0.0079717 + "bias": 0.00274533, + "spread": 0.00867502, + "score": 0.00909905 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00169377, - "spread": 0.00404068, - "score": 0.00438132 + "bias": 3.02e-05, + "spread": 0.00246883, + "score": 0.00246901 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00194248, - "spread": 0.00486307, - "score": 0.00523666 + "bias": 0.00130489, + "spread": 0.01059478, + "score": 0.01067484 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00344207, - "spread": 0.00305898, - "score": 0.00460491 + "bias": 0.01067225, + "spread": 0.00661282, + "score": 0.01255493 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.0471605, + "bias": -0.04177856, "spread": 0.0, - "score": 0.0471605 + "score": 0.04177856 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.0097794, - "spread": 0.01469675, - "score": 0.01765307 + "bias": -0.0103005, + "spread": 0.01648308, + "score": 0.01943688 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00115104, - "spread": 0.00545578, - "score": 0.00557588 + "bias": 0.00674607, + "spread": 0.00755689, + "score": 0.01012996 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00336632, - "spread": 0.0032976, - "score": 0.00471236 + "bias": 0.00098062, + "spread": 0.00376164, + "score": 0.00388736 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00445479, - "spread": 0.00863603, - "score": 0.00971731 + "bias": 0.00630443, + "spread": 0.00515308, + "score": 0.00814248 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01038448, - "spread": 0.01294471, - "score": 0.01659527 + "bias": 0.0140488, + "spread": 0.01363939, + "score": 0.01958065 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.01601289, - "spread": 0.03562288, - "score": 0.0390564 + "bias": 0.01734808, + "spread": 0.02118042, + "score": 0.02737821 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.0923988, - "spread": 0.16141034, - "score": 0.18598612 + "bias": 0.11086729, + "spread": 0.18545402, + "score": 0.21606654 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.5428003, - "spread": 1.17919406, - "score": 1.29812587 + "bias": 0.83134354, + "spread": 1.70885001, + "score": 1.90034219 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.06812589, - "spread": 0.19686685, - "score": 0.20832113 + "bias": -0.08660751, + "spread": 0.23126584, + "score": 0.24695091 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.0603787, - "spread": 0.11418187, - "score": 0.12916302 + "bias": -0.05169655, + "spread": 0.2424716, + "score": 0.24792138 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00033171, - "spread": 0.00745394, - "score": 0.00746132 + "bias": 0.00439753, + "spread": 0.00765634, + "score": 0.00882937 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00455544, - "spread": 0.00319252, - "score": 0.00556275 + "bias": 0.00833633, + "spread": 0.00447491, + "score": 0.00946146 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00892199, - "spread": 0.00875758, - "score": 0.01250188 + "bias": 0.0110369, + "spread": 0.00691273, + "score": 0.01302302 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00144354, - "spread": 0.00262221, - "score": 0.00299329 + "bias": 0.0015726, + "spread": 0.00284574, + "score": 0.00325135 }, { "profile": "ti_dependent_cp", @@ -7255,9 +7255,9 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.07953528, - "spread": 0.11480244, - "score": 0.13966195 + "bias": -0.10152551, + "spread": 0.10950011, + "score": 0.14932416 }, { "profile": "ti_dependent_cp", @@ -7291,225 +7291,225 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00597658, - "spread": 0.0130448, - "score": 0.01434873 + "bias": 0.00309093, + "spread": 0.01619938, + "score": 0.01649163 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00373515, - "spread": 0.00331767, - "score": 0.00499583 + "bias": 0.00501668, + "spread": 0.00807952, + "score": 0.0095103 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00561061, - "spread": 0.00432203, - "score": 0.00708229 + "bias": 0.00085771, + "spread": 0.00520441, + "score": 0.00527462 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "overall", "condition_bin": "overall", - "bias": 0.00211368, - "spread": 0.00196792, - "score": 0.00288797 + "bias": 0.00331971, + "spread": 0.00236364, + "score": 0.0040752 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00905632, - "spread": 0.01291995, - "score": 0.01577789 + "bias": 0.01137946, + "spread": 0.01090495, + "score": 0.01576103 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00172391, - "spread": 0.01418339, - "score": 0.01428777 + "bias": 0.00355899, + "spread": 0.01249201, + "score": 0.0129891 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.0022034, - "spread": 0.01070219, - "score": 0.01092666 + "bias": -0.0026989, + "spread": 0.00970667, + "score": 0.0100749 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00030417, - "spread": 0.00283723, - "score": 0.00285349 + "bias": 0.00113502, + "spread": 0.00583215, + "score": 0.00594157 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00667616, - "spread": 0.01761667, - "score": 0.01883927 + "bias": -0.00017829, + "spread": 0.01215885, + "score": 0.01216016 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00625187, - "spread": 0.00708088, - "score": 0.00944588 + "bias": 0.0056467, + "spread": 0.00651345, + "score": 0.00862034 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.00091805, - "spread": 0.04567174, - "score": 0.04568097 + "bias": -0.00365143, + "spread": 0.04580299, + "score": 0.04594831 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.01233378, - "spread": 0.01557114, - "score": 0.0198641 + "bias": -0.01089043, + "spread": 0.0167233, + "score": 0.01995671 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00363952, - "spread": 0.00503531, - "score": 0.00621293 + "bias": 0.004809, + "spread": 0.0042134, + "score": 0.00639369 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00192093, - "spread": 0.00055578, - "score": 0.00199971 + "bias": -0.00098642, + "spread": 0.0049187, + "score": 0.00501663 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00490264, - "spread": 0.00928411, - "score": 0.01049907 + "bias": 0.00502725, + "spread": 0.00973012, + "score": 0.0109521 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01733007, - "spread": 0.01974002, - "score": 0.02626784 + "bias": 0.01668404, + "spread": 0.01794352, + "score": 0.02450157 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00812841, - "spread": 0.04360184, - "score": 0.04435303 + "bias": 0.00608483, + "spread": 0.04381446, + "score": 0.04423496 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.10286383, - "spread": 0.10973732, - "score": 0.15041026 + "bias": 0.10658176, + "spread": 0.1069664, + "score": 0.1510016 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.23402495, - "spread": 0.41557474, - "score": 0.47693819 + "bias": 2.41365863, + "spread": 3.86532209, + "score": 4.55702346 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.00961822, - "spread": 0.06278808, - "score": 0.06352049 + "bias": 0.01026211, + "spread": 0.06024147, + "score": 0.06110929 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.16026441, - "spread": 0.12535548, - "score": 0.20346664 + "bias": -0.13366491, + "spread": 0.03511204, + "score": 0.13819972 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00026636, - "spread": 0.00608917, - "score": 0.00609499 + "bias": -0.00128096, + "spread": 0.00580119, + "score": 0.00594093 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00794914, - "spread": 0.00882533, - "score": 0.01187751 + "bias": 0.01080005, + "spread": 0.01052877, + "score": 0.01508297 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.01067665, - "spread": 0.01242377, - "score": 0.01638111 + "bias": 0.01235086, + "spread": 0.013043, + "score": 0.01796284 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00196547, - "spread": 0.00487542, - "score": 0.00525669 + "bias": 0.00093552, + "spread": 0.0051008, + "score": 0.00518588 }, { "profile": "ti_dependent_cp", @@ -7525,9 +7525,9 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.11166349, - "spread": 0.13366126, - "score": 0.17416678 + "bias": -0.11618833, + "spread": 0.13161004, + "score": 0.17555891 }, { "profile": "ti_dependent_cp", @@ -7561,252 +7561,252 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00311746, - "spread": 0.01825068, - "score": 0.01851502 + "bias": 0.01299976, + "spread": 0.01535448, + "score": 0.02011849 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00096158, - "spread": 0.0091357, - "score": 0.00918616 + "bias": 0.00137095, + "spread": 0.00932686, + "score": 0.00942708 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.0020272, - "spread": 0.00643954, - "score": 0.00675109 + "bias": -0.00188827, + "spread": 0.00825524, + "score": 0.00846844 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "overall", "condition_bin": "overall", - "bias": 0.00422292, - "spread": 0.00194523, - "score": 0.0046494 + "bias": 0.0048288, + "spread": 0.00343958, + "score": 0.00592857 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00575992, - "spread": 0.01561659, - "score": 0.01664496 + "bias": 0.00463806, + "spread": 0.01917745, + "score": 0.01973034 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00122633, - "spread": 0.00412885, - "score": 0.00430712 + "bias": 0.00030401, + "spread": 0.00592629, + "score": 0.00593409 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.0026191, - "spread": 0.00657273, - "score": 0.00707534 + "bias": 0.00322859, + "spread": 0.00664747, + "score": 0.00739004 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00048978, - "spread": 0.00195744, - "score": 0.00201779 + "bias": 0.0018782, + "spread": 0.00100784, + "score": 0.00213152 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00329025, - "spread": 0.00908451, - "score": 0.00966199 + "bias": 0.00484786, + "spread": 0.0080999, + "score": 0.00943981 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00629042, - "spread": 0.00689705, - "score": 0.00933481 + "bias": 0.00737854, + "spread": 0.00590679, + "score": 0.00945162 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.05255795, - "spread": 0.00380152, - "score": 0.05269525 + "bias": -0.05446568, + "spread": 0.00317963, + "score": 0.05455842 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00745651, - "spread": 0.01164008, - "score": 0.01382357 + "bias": -0.00742739, + "spread": 0.01531896, + "score": 0.01702459 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00192273, - "spread": 0.00168341, - "score": 0.00255554 + "bias": 0.00309208, + "spread": 0.00296367, + "score": 0.00428303 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00152142, - "spread": 0.00187376, - "score": 0.00241365 + "bias": 0.00299841, + "spread": 0.00270043, + "score": 0.0040352 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.01143144, - "spread": 0.00537287, - "score": 0.01263113 + "bias": 0.00905922, + "spread": 0.00672997, + "score": 0.01128548 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.02533713, - "spread": 0.00687573, - "score": 0.02625349 + "bias": 0.01871619, + "spread": 0.00945631, + "score": 0.02096944 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.03293296, - "spread": 0.01392438, - "score": 0.03575568 + "bias": 0.02405919, + "spread": 0.01401844, + "score": 0.02784531 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.1023284, - "spread": 0.08207299, - "score": 0.13117575 + "bias": 0.0871886, + "spread": 0.05942632, + "score": 0.10551464 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.21995887, - "spread": 0.10686038, - "score": 0.24454252 + "bias": 0.19925868, + "spread": 0.09596436, + "score": 0.22116324 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.0042919, - "spread": 0.44634411, - "score": 0.44636474 + "bias": 0.01789598, + "spread": 0.31993183, + "score": 0.32043196 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.08626944, - "spread": 0.15666804, - "score": 0.17884991 + "bias": -0.0510562, + "spread": 0.150867, + "score": 0.15927205 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00113697, - "spread": 0.00300452, - "score": 0.00321245 + "bias": -0.00015835, + "spread": 0.00487727, + "score": 0.00487984 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.0063365, - "spread": 0.00422148, - "score": 0.00761394 + "bias": 0.0074896, + "spread": 0.00543581, + "score": 0.00925431 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00625211, - "spread": 0.00440697, - "score": 0.0076492 + "bias": 0.00861477, + "spread": 0.00581585, + "score": 0.01039415 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00336263, - "spread": 0.00330458, - "score": 0.0047146 + "bias": 0.00456219, + "spread": 0.00391566, + "score": 0.00601215 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 0.00076722, - "spread": 0.00254555, - "score": 0.00265865 + "bias": 0.00044932, + "spread": 0.00202329, + "score": 0.00207258 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.13584131, - "spread": 0.0927196, - "score": 0.16446819 + "bias": -0.14004048, + "spread": 0.10754095, + "score": 0.17656838 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": -0.00346358, - "spread": 0.00316556, - "score": 0.00469225 + "bias": -0.00293463, + "spread": 0.00341567, + "score": 0.00450321 }, { "profile": "ti_dependent_cp", @@ -7831,252 +7831,252 @@ "campaign_months": 6, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.01040474, - "spread": 0.0085229, - "score": 0.01344985 + "bias": 0.01187755, + "spread": 0.01116296, + "score": 0.01629993 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00482171, - "spread": 0.00438774, - "score": 0.00651929 + "bias": 0.00647053, + "spread": 0.00327645, + "score": 0.00725278 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00406475, - "spread": 0.00644837, - "score": 0.00762257 + "bias": 0.00405563, + "spread": 0.00613733, + "score": 0.00735629 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "overall", "condition_bin": "overall", - "bias": 0.00095646, - "spread": 0.00347279, - "score": 0.0036021 + "bias": 0.00142191, + "spread": 0.00206643, + "score": 0.00250838 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.00308628, - "spread": 0.00487118, - "score": 0.00576659 + "bias": -0.00920777, + "spread": 0.00718877, + "score": 0.01168167 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00274139, - "spread": 0.00687668, - "score": 0.00740297 + "bias": -0.00114603, + "spread": 0.00433908, + "score": 0.00448787 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": -0.0006817, - "spread": 0.00095636, - "score": 0.00117446 + "bias": 0.00059673, + "spread": 0.00156809, + "score": 0.00167779 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -1.52e-06, - "spread": 0.00039675, - "score": 0.00039676 + "bias": 0.00078505, + "spread": 0.00085656, + "score": 0.00116189 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00071264, - "spread": 0.00716559, - "score": 0.00720095 + "bias": 0.00123927, + "spread": 0.00382825, + "score": 0.00402384 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00152621, - "spread": 0.00714364, - "score": 0.00730486 + "bias": 0.00263629, + "spread": 0.00535769, + "score": 0.00597116 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.01265874, - "spread": 0.0476134, - "score": 0.04926743 + "bias": -0.01219329, + "spread": 0.04894012, + "score": 0.05043622 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00963878, - "spread": 0.00225502, - "score": 0.00989905 + "bias": -0.00950449, + "spread": 0.00511581, + "score": 0.01079383 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00189131, - "spread": 0.00274194, - "score": 0.00333096 + "bias": -0.00108279, + "spread": 0.00188103, + "score": 0.00217042 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00024445, - "spread": 0.00419765, - "score": 0.00420476 + "bias": 0.00116721, + "spread": 0.00202101, + "score": 0.00233385 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00713136, - "spread": 0.00430492, - "score": 0.00832998 + "bias": 0.00474253, + "spread": 0.0044131, + "score": 0.0064782 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.02120273, - "spread": 0.00635918, - "score": 0.02213583 + "bias": 0.01562457, + "spread": 0.00499829, + "score": 0.01640458 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.02013216, - "spread": 0.01308514, - "score": 0.02401093 + "bias": 0.01717915, + "spread": 0.012829, + "score": 0.02144077 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.07074513, - "spread": 0.04609487, - "score": 0.08443702 + "bias": 0.05823459, + "spread": 0.03645091, + "score": 0.0687018 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.41078627, - "spread": 0.41306172, - "score": 0.58255072 + "bias": 0.32319006, + "spread": 0.3374079, + "score": 0.46722147 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.47221773, - "spread": 0.6946849, - "score": 0.83998613 + "bias": 0.41185828, + "spread": 0.58995727, + "score": 0.71949762 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.10456969, - "spread": 0.08880712, - "score": 0.13719156 + "bias": -0.06361927, + "spread": 0.09715868, + "score": 0.11613449 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00168685, - "spread": 0.00370504, - "score": 0.00407097 + "bias": -0.00057687, + "spread": 0.00287687, + "score": 0.00293414 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.003381, - "spread": 0.00209828, - "score": 0.00397919 + "bias": 0.00284247, + "spread": 0.00198168, + "score": 0.00346507 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.0024736, - "spread": 0.00209432, - "score": 0.00324112 + "bias": 0.00270907, + "spread": 0.00239003, + "score": 0.00361266 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00078635, - "spread": 0.00068416, - "score": 0.00104232 + "bias": 0.00085692, + "spread": 0.00141319, + "score": 0.0016527 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 0.00070325, - "spread": 0.00216548, - "score": 0.00227681 + "bias": 0.0007386, + "spread": 0.00153378, + "score": 0.00170236 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.14118964, - "spread": 0.08812333, - "score": 0.16643388 + "bias": -0.14625397, + "spread": 0.09967643, + "score": 0.17699043 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": -0.00099855, - "spread": 0.01182167, - "score": 0.01186377 + "bias": 9.058e-05, + "spread": 0.01229782, + "score": 0.01229816 }, { "profile": "ti_dependent_cp", @@ -8101,90 +8101,90 @@ "campaign_months": 12, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00273189, - "spread": 0.0078202, - "score": 0.00828364 + "bias": 0.00155008, + "spread": 0.00387643, + "score": 0.00417486 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.0019747, - "spread": 0.00694992, - "score": 0.00722502 + "bias": 0.00281959, + "spread": 0.00463533, + "score": 0.00542553 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00030219, - "spread": 0.00744431, - "score": 0.00745044 + "bias": 0.00149721, + "spread": 0.00545973, + "score": 0.00566129 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "overall", "condition_bin": "overall", - "bias": -0.00444666, - "spread": 0.0115772, - "score": 0.01240179 + "bias": -0.00182459, + "spread": 0.00500356, + "score": 0.00532585 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.02156958, - "spread": 0.03070039, - "score": 0.03752014 + "bias": -0.03530391, + "spread": 0.04932889, + "score": 0.06066058 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00026803, - "spread": 0.02548297, - "score": 0.02548438 + "bias": 0.00038862, + "spread": 0.01190718, + "score": 0.01191352 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.02040232, - "spread": 0.02155565, - "score": 0.02967997 + "bias": 0.02179555, + "spread": 0.01794959, + "score": 0.02823533 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00336006, - "spread": 0.00236412, - "score": 0.00410841 + "bias": 0.00163082, + "spread": 0.00288289, + "score": 0.00331219 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.03033452, - "spread": 0.03184295, - "score": 0.04397905 + "bias": -0.0201503, + "spread": 0.0178442, + "score": 0.02691561 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00861721, - "spread": 0.00981022, - "score": 0.01305744 + "bias": -0.0065825, + "spread": 0.01210113, + "score": 0.01377558 }, { "profile": "ws_dependent_cp", @@ -8200,126 +8200,126 @@ "campaign_months": 1, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.01628118, - "spread": 0.01899887, - "score": 0.02502067 + "bias": -0.01225252, + "spread": 0.01663763, + "score": 0.0206624 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.01040169, - "spread": 0.0164322, - "score": 0.01944769 + "bias": -0.00841988, + "spread": 0.01329803, + "score": 0.0157395 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00532525, - "spread": 0.0088218, - "score": 0.01030448 + "bias": -0.00314662, + "spread": 0.00606946, + "score": 0.00683663 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00067286, - "spread": 0.02232708, - "score": 0.02233722 + "bias": 0.00233615, + "spread": 0.00841058, + "score": 0.00872901 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01146791, - "spread": 0.04454612, - "score": 0.04599859 + "bias": 0.02043818, + "spread": 0.02175854, + "score": 0.02985219 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.02887723, - "spread": 0.00860709, - "score": 0.03013265 + "bias": 0.02856117, + "spread": 0.03223532, + "score": 0.04306805 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.3478321, - "spread": 0.68591266, - "score": 0.76906654 + "bias": 0.32943928, + "spread": 0.72713457, + "score": 0.79828248 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.03766409, - "spread": 0.0671303, - "score": 0.07697441 + "bias": 0.00027399, + "spread": 0.05382565, + "score": 0.05382635 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.09473355, - "spread": 0.10582127, - "score": 0.14203023 + "bias": -0.09211148, + "spread": 0.10166018, + "score": 0.13718352 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.07538137, - "spread": 0.08007619, - "score": 0.10997521 + "bias": 0.03436578, + "spread": 0.08779221, + "score": 0.09427873 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00481551, - "spread": 0.01878702, - "score": 0.01939436 + "bias": 0.00543639, + "spread": 0.01350971, + "score": 0.01456251 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.01015667, - "spread": 0.02160839, - "score": 0.02387636 + "bias": 0.0084761, + "spread": 0.01974164, + "score": 0.02148434 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00477256, - "spread": 0.00949688, - "score": 0.01062864 + "bias": 0.00503514, + "spread": 0.00836509, + "score": 0.00976357 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00680091, - "spread": 0.01177952, - "score": 0.01360182 + "bias": 0.00494281, + "spread": 0.0085612, + "score": 0.00988562 }, { "profile": "ws_dependent_cp", @@ -8335,9 +8335,9 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.21860916, - "spread": 0.23266616, - "score": 0.31925462 + "bias": -0.21628694, + "spread": 0.2132484, + "score": 0.30373495 }, { "profile": "ws_dependent_cp", @@ -8371,225 +8371,225 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.0105696, - "spread": 0.01493437, - "score": 0.01829623 + "bias": -0.00849672, + "spread": 0.02438553, + "score": 0.02582341 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00535259, - "spread": 0.01371195, - "score": 0.01471964 + "bias": -0.0029482, + "spread": 0.01324984, + "score": 0.01357387 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.01551555, - "spread": 0.01761969, - "score": 0.02347735 + "bias": -0.01025116, + "spread": 0.00973031, + "score": 0.01413383 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "overall", "condition_bin": "overall", - "bias": -9.224e-05, - "spread": 0.00202259, - "score": 0.00202469 + "bias": 0.00431961, + "spread": 0.00496042, + "score": 0.0065776 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.01770201, - "spread": 0.01134504, - "score": 0.02102549 + "bias": -0.02565397, + "spread": 0.03202106, + "score": 0.04103016 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00563173, - "spread": 0.00912778, - "score": 0.01072533 + "bias": 0.00315751, + "spread": 0.0071958, + "score": 0.00785807 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00945897, - "spread": 0.00771742, - "score": 0.01220781 + "bias": 0.00963187, + "spread": 0.00904836, + "score": 0.01321536 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.0025373, - "spread": 0.00250639, - "score": 0.00356649 + "bias": 0.00017052, + "spread": 0.00321658, + "score": 0.00322109 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00621793, - "spread": 0.00480209, - "score": 0.00785638 + "bias": -0.00130911, + "spread": 0.0126294, + "score": 0.01269707 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00238348, - "spread": 0.00449783, - "score": 0.00509033 + "bias": 0.00981876, + "spread": 0.0055587, + "score": 0.01128305 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.01484423, + "bias": 0.01896836, "spread": 0.0, - "score": 0.01484423 + "score": 0.01896836 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00227073, - "spread": 0.01142026, - "score": 0.01164382 + "bias": -0.00433064, + "spread": 0.01270719, + "score": 0.01342487 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00346786, - "spread": 0.00436936, - "score": 0.00557829 + "bias": 0.00821873, + "spread": 0.00655014, + "score": 0.01050961 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.0022665, - "spread": 0.00210504, - "score": 0.00309326 + "bias": 0.0022463, + "spread": 0.00354319, + "score": 0.00419524 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00021155, - "spread": 0.00884097, - "score": 0.0088435 + "bias": 0.00308315, + "spread": 0.00597637, + "score": 0.0067248 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00463714, - "spread": 0.01767916, - "score": 0.0182772 + "bias": 0.0012729, + "spread": 0.01628857, + "score": 0.01633823 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00461712, - "spread": 0.03635813, - "score": 0.03665012 + "bias": -0.00469612, + "spread": 0.02380035, + "score": 0.02425923 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.06488519, - "spread": 0.15889245, - "score": 0.17163012 + "bias": 0.07323419, + "spread": 0.19533538, + "score": 0.20861246 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.54876814, - "spread": 1.23719547, - "score": 1.35343973 + "bias": 0.76543799, + "spread": 1.65529563, + "score": 1.82370472 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.5868564, - "spread": 1.33661952, - "score": 1.45977813 + "bias": -0.6061075, + "spread": 1.37100326, + "score": 1.49900508 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.01378846, - "spread": 0.15894199, - "score": 0.15953896 + "bias": 1.1900487, + "spread": 2.14140859, + "score": 2.44986667 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00466613, - "spread": 0.00843827, - "score": 0.00964247 + "bias": 0.00900168, + "spread": 0.00721716, + "score": 0.01153766 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00458755, - "spread": 0.00470696, - "score": 0.00657275 + "bias": 0.00880338, + "spread": 0.00548093, + "score": 0.01037016 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00941956, - "spread": 0.00917955, - "score": 0.01315265 + "bias": 0.01176149, + "spread": 0.00771247, + "score": 0.01406467 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00140326, - "spread": 0.00243051, - "score": 0.00280652 + "bias": 0.00213256, + "spread": 0.0036937, + "score": 0.00426511 }, { "profile": "ws_dependent_cp", @@ -8605,9 +8605,9 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.10566132, - "spread": 0.1322587, - "score": 0.16928284 + "bias": -0.13809694, + "spread": 0.13178843, + "score": 0.1908899 }, { "profile": "ws_dependent_cp", @@ -8641,225 +8641,225 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.0107594, - "spread": 0.01262949, - "score": 0.01659123 + "bias": -0.00301207, + "spread": 0.02003741, + "score": 0.02026254 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00026945, - "spread": 0.00464673, - "score": 0.00465454 + "bias": 0.00249097, + "spread": 0.00847782, + "score": 0.0088362 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00547438, - "spread": 0.00536221, - "score": 0.00766304 + "bias": 0.00109557, + "spread": 0.00736725, + "score": 0.00744826 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "overall", "condition_bin": "overall", - "bias": 0.00211136, - "spread": 0.00196081, - "score": 0.00288143 + "bias": 0.00374989, + "spread": 0.00292815, + "score": 0.0047577 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00819734, - "spread": 0.01240588, - "score": 0.01486951 + "bias": 0.01016754, + "spread": 0.01305485, + "score": 0.01654714 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00012136, - "spread": 0.01117508, - "score": 0.01117574 + "bias": 0.00374154, + "spread": 0.01242417, + "score": 0.01297533 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00808478, - "spread": 0.01261675, - "score": 0.01498486 + "bias": 0.00409343, + "spread": 0.00978777, + "score": 0.01060927 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00025162, - "spread": 0.00344477, - "score": 0.00345395 + "bias": 0.00049442, + "spread": 0.00551626, + "score": 0.00553837 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00798192, - "spread": 0.01744915, - "score": 0.01918812 + "bias": -0.00014555, + "spread": 0.01437757, + "score": 0.01437831 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00342653, - "spread": 0.00886646, - "score": 0.00950554 + "bias": 0.0054299, + "spread": 0.00709821, + "score": 0.00893691 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.02672914, - "spread": 0.01219055, - "score": 0.02937783 + "bias": 0.02429795, + "spread": 0.01246524, + "score": 0.02730884 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.0066548, - "spread": 0.01176891, - "score": 0.01352012 + "bias": -0.00725355, + "spread": 0.01446084, + "score": 0.01617807 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00636325, - "spread": 0.00439492, - "score": 0.00773345 + "bias": 0.00683475, + "spread": 0.00433855, + "score": 0.00809548 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00096049, - "spread": 0.00058323, - "score": 0.0011237 + "bias": 0.00028269, + "spread": 0.00514466, + "score": 0.00515243 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00015493, - "spread": 0.01002826, - "score": 0.01002945 + "bias": 0.00209267, + "spread": 0.01166121, + "score": 0.01184749 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00331372, - "spread": 0.02221464, - "score": 0.02246043 + "bias": 0.0052796, + "spread": 0.0246183, + "score": 0.02517806 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.02750877, - "spread": 0.04404974, - "score": 0.05193372 + "bias": -0.01595823, + "spread": 0.05294344, + "score": 0.05529623 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.08021484, - "spread": 0.1203293, - "score": 0.14461521 + "bias": 0.08948243, + "spread": 0.10434726, + "score": 0.13746074 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.25382424, - "spread": 0.40560454, - "score": 0.47847861 + "bias": 1.76699577, + "spread": 2.77387496, + "score": 3.28886855 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.00903268, - "spread": 0.32503493, - "score": 0.32516041 + "bias": 0.01826746, + "spread": 0.31101767, + "score": 0.31155367 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.12509488, - "spread": 0.23218152, - "score": 0.26373659 + "bias": -0.18827291, + "spread": 0.12411547, + "score": 0.22550241 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00360203, - "spread": 0.0073917, - "score": 0.00822264 + "bias": 0.00292608, + "spread": 0.00685658, + "score": 0.00745483 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00788165, - "spread": 0.00979995, - "score": 0.01257615 + "bias": 0.01071782, + "spread": 0.01170386, + "score": 0.01586984 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.01181414, - "spread": 0.01273381, - "score": 0.0173702 + "bias": 0.01252267, + "spread": 0.01340334, + "score": 0.01834303 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00234415, - "spread": 0.00540432, - "score": 0.00589082 + "bias": 0.00058187, + "spread": 0.00439095, + "score": 0.00442934 }, { "profile": "ws_dependent_cp", @@ -8875,9 +8875,9 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.13921445, - "spread": 0.15372702, - "score": 0.20739494 + "bias": -0.14369938, + "spread": 0.16163105, + "score": 0.21627323 }, { "profile": "ws_dependent_cp", @@ -8911,252 +8911,252 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00188999, - "spread": 0.01751105, - "score": 0.01761275 + "bias": 0.01022467, + "spread": 0.01679739, + "score": 0.01966459 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00418007, - "spread": 0.00988696, - "score": 0.01073429 + "bias": -2.173e-05, + "spread": 0.01118863, + "score": 0.01118865 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00270493, - "spread": 0.00740434, - "score": 0.00788295 + "bias": -0.00249097, + "spread": 0.00887992, + "score": 0.00922268 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "overall", "condition_bin": "overall", - "bias": 0.00423481, - "spread": 0.00193686, - "score": 0.00465672 + "bias": 0.0049204, + "spread": 0.00346512, + "score": 0.00601809 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.0043769, - "spread": 0.01517585, - "score": 0.01579442 + "bias": 0.00610851, + "spread": 0.01856028, + "score": 0.01953965 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00026052, - "spread": 0.00349253, - "score": 0.00350223 + "bias": 0.00101171, + "spread": 0.00447795, + "score": 0.00459082 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00804502, - "spread": 0.00652952, - "score": 0.01036132 + "bias": 0.00759375, + "spread": 0.00678854, + "score": 0.01018574 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00011228, - "spread": 0.00207289, - "score": 0.00207593 + "bias": 0.00155062, + "spread": 0.00119203, + "score": 0.00195586 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00128487, - "spread": 0.00943332, - "score": 0.00952042 + "bias": 0.00441077, + "spread": 0.00753985, + "score": 0.00873523 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00483381, - "spread": 0.00700065, - "score": 0.00850735 + "bias": 0.00602861, + "spread": 0.0047332, + "score": 0.00766469 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.01872078, - "spread": 0.02121536, - "score": 0.02829416 + "bias": -0.02068598, + "spread": 0.02090021, + "score": 0.02940626 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00344463, - "spread": 0.01212402, - "score": 0.01260386 + "bias": -0.00456993, + "spread": 0.01486084, + "score": 0.01554763 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00456983, - "spread": 0.00226617, - "score": 0.00510087 + "bias": 0.00493739, + "spread": 0.00314165, + "score": 0.00585216 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00225466, - "spread": 0.00205125, - "score": 0.00304814 + "bias": 0.00371102, + "spread": 0.00331178, + "score": 0.00497389 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00659972, - "spread": 0.00681789, - "score": 0.00948894 + "bias": 0.00577779, + "spread": 0.00781533, + "score": 0.00971917 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01254008, - "spread": 0.00801545, - "score": 0.01488292 + "bias": 0.00800092, + "spread": 0.0089562, + "score": 0.0120095 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.01328207, - "spread": 0.01479382, - "score": 0.01988141 + "bias": 0.01079632, + "spread": 0.0135287, + "score": 0.01730856 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.07573607, - "spread": 0.0791303, - "score": 0.10953335 + "bias": 0.07113181, + "spread": 0.06907352, + "score": 0.09915082 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.13867397, - "spread": 0.21466132, - "score": 0.25555812 + "bias": 0.10614683, + "spread": 0.22199025, + "score": 0.24606264 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.00170367, - "spread": 0.45045746, - "score": 0.45046068 + "bias": -0.08410399, + "spread": 0.48566705, + "score": 0.49289549 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.1045341, - "spread": 0.1429685, - "score": 0.17710836 + "bias": -0.07732003, + "spread": 0.12660865, + "score": 0.1483514 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00326311, - "spread": 0.00287076, - "score": 0.00434616 + "bias": 0.00328707, + "spread": 0.00485063, + "score": 0.00585948 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00640381, - "spread": 0.00521626, - "score": 0.00825942 + "bias": 0.00733907, + "spread": 0.00661673, + "score": 0.00988145 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00686944, - "spread": 0.0045361, - "score": 0.00823198 + "bias": 0.00907472, + "spread": 0.00672937, + "score": 0.01129756 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.0044869, - "spread": 0.002866, - "score": 0.00532412 + "bias": 0.00558556, + "spread": 0.00462934, + "score": 0.0072546 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 0.00154734, - "spread": 0.00268006, - "score": 0.00309467 + "bias": 0.00140935, + "spread": 0.00244106, + "score": 0.0028187 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.17798176, - "spread": 0.11864709, - "score": 0.21390334 + "bias": -0.17727804, + "spread": 0.13113168, + "score": 0.22050628 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": 0.00068275, - "spread": 0.00118255, - "score": 0.00136549 + "bias": 0.00147121, + "spread": 0.00254821, + "score": 0.00294242 }, { "profile": "ws_dependent_cp", @@ -9181,252 +9181,252 @@ "campaign_months": 6, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00635902, - "spread": 0.00922311, - "score": 0.01120281 + "bias": 0.00952629, + "spread": 0.01107238, + "score": 0.01460643 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00142358, - "spread": 0.00503475, - "score": 0.00523214 + "bias": 0.00440098, + "spread": 0.00316874, + "score": 0.00542306 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00323781, - "spread": 0.0068676, - "score": 0.00759259 + "bias": 0.00325219, + "spread": 0.00655689, + "score": 0.00731912 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "overall", "condition_bin": "overall", - "bias": 0.00099214, - "spread": 0.00345376, - "score": 0.00359344 + "bias": 0.00122321, + "spread": 0.00205919, + "score": 0.0023951 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.00409495, - "spread": 0.00445688, - "score": 0.00605247 + "bias": -0.00936334, + "spread": 0.00819788, + "score": 0.01244497 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00154944, - "spread": 0.00664061, - "score": 0.00681898 + "bias": -0.00081601, + "spread": 0.00454951, + "score": 0.00462212 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00317057, - "spread": 0.00114633, - "score": 0.00337143 + "bias": 0.00229858, + "spread": 0.00107911, + "score": 0.00253929 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00058397, - "spread": 0.00018921, - "score": 0.00061386 + "bias": 0.00032951, + "spread": 0.00113906, + "score": 0.00118576 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00034827, - "spread": 0.00729854, - "score": 0.00730685 + "bias": 0.00068025, + "spread": 0.00372158, + "score": 0.00378323 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00112313, - "spread": 0.00718264, - "score": 0.00726992 + "bias": 0.0029849, + "spread": 0.00458555, + "score": 0.00547146 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.00020516, - "spread": 0.02985593, - "score": 0.02985663 + "bias": 0.00043622, + "spread": 0.0313205, + "score": 0.03132353 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.0034837, - "spread": 0.00191158, - "score": 0.0039737 + "bias": -0.00468852, + "spread": 0.00341142, + "score": 0.00579828 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 3.076e-05, - "spread": 0.00258414, - "score": 0.00258433 + "bias": 0.00017081, + "spread": 0.00166847, + "score": 0.00167719 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00017772, - "spread": 0.00415501, - "score": 0.00415881 + "bias": 0.00118119, + "spread": 0.00201628, + "score": 0.00233679 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.0034924, - "spread": 0.00533818, - "score": 0.00637911 + "bias": 0.00226006, + "spread": 0.00502127, + "score": 0.00550645 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01012957, - "spread": 0.00684269, - "score": 0.01222418 + "bias": 0.00659982, + "spread": 0.00496655, + "score": 0.0082598 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.00270306, - "spread": 0.01399959, - "score": 0.01425816 + "bias": 0.00044534, + "spread": 0.01302538, + "score": 0.01303299 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.05394713, - "spread": 0.04867401, - "score": 0.07265984 + "bias": 0.04274803, + "spread": 0.03973376, + "score": 0.05836237 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.40229909, - "spread": 0.44764468, - "score": 0.60185573 + "bias": 0.32729879, + "spread": 0.38816991, + "score": 0.50774046 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.71321819, - "spread": 0.74368607, - "score": 1.03041213 + "bias": 0.657283, + "spread": 0.6577865, + "score": 0.92989463 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.13057929, - "spread": 0.10571637, - "score": 0.16800863 + "bias": -0.06574237, + "spread": 0.10056509, + "score": 0.1201474 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00221441, - "spread": 0.00292851, - "score": 0.00367148 + "bias": 0.00211781, + "spread": 0.00236574, + "score": 0.00317519 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00286796, - "spread": 0.00257825, - "score": 0.00385649 + "bias": 0.00242731, + "spread": 0.0024236, + "score": 0.00343011 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00257105, - "spread": 0.0023172, - "score": 0.00346117 + "bias": 0.00274423, + "spread": 0.00273727, + "score": 0.00387601 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00121112, - "spread": 0.00088516, - "score": 0.00150011 + "bias": 0.00111927, + "spread": 0.00177989, + "score": 0.00210256 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 0.00199909, - "spread": 0.00286987, - "score": 0.0034975 + "bias": 0.00212316, + "spread": 0.00245766, + "score": 0.00324775 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.17875648, - "spread": 0.11173531, - "score": 0.21080479 + "bias": -0.18100661, + "spread": 0.12497696, + "score": 0.21996053 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": 0.00320432, - "spread": 0.0119194, - "score": 0.0123426 + "bias": 0.00467245, + "spread": 0.01295333, + "score": 0.01377028 }, { "profile": "ws_dependent_cp", @@ -9451,33 +9451,33 @@ "campaign_months": 12, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00124193, - "spread": 0.00811732, - "score": 0.00821177 + "bias": -0.00126013, + "spread": 0.00473498, + "score": 0.00489979 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00085335, - "spread": 0.00748446, - "score": 0.00753296 + "bias": 0.00036059, + "spread": 0.00469422, + "score": 0.00470805 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00052189, - "spread": 0.00779285, - "score": 0.00781031 + "bias": 0.00027292, + "spread": 0.00578776, + "score": 0.0057942 } ] }, "toggle": { - "recorded_utc": "2026-07-16T10:27:18Z", - "git_commit": "4f07d64", + "recorded_utc": "2026-09-03T19:12:27Z", + "git_commit": "cbe3297", "n_replicates": 4, "seed": 0, "campaign_months": [ @@ -9502,63 +9502,63 @@ "campaign_months": 1, "condition": "overall", "condition_bin": "overall", - "bias": -0.0030467, - "spread": 0.00227078, - "score": 0.00379984 + "bias": -0.00347973, + "spread": 0.00330458, + "score": 0.00479883 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.01278279, - "spread": 0.02543963, - "score": 0.02847059 + "bias": 0.01175615, + "spread": 0.02643525, + "score": 0.02893146 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00143459, - "spread": 0.00864897, - "score": 0.00876713 + "bias": -0.00234096, + "spread": 0.00632442, + "score": 0.00674376 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.0018796, - "spread": 0.01103415, - "score": 0.0111931 + "bias": 0.00033385, + "spread": 0.01188836, + "score": 0.01189304 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.0004396, - "spread": 0.0036932, - "score": 0.00371927 + "bias": 0.00052971, + "spread": 0.00373165, + "score": 0.00376906 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00350659, - "spread": 0.0092041, - "score": 0.00984945 + "bias": -0.00833965, + "spread": 0.01083014, + "score": 0.013669 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00935936, - "spread": 0.0089177, - "score": 0.01292761 + "bias": -0.00650457, + "spread": 0.00798074, + "score": 0.01029571 }, { "profile": "cp_0pct", @@ -9574,117 +9574,117 @@ "campaign_months": 1, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00603035, - "spread": 0.00229104, - "score": 0.00645089 + "bias": -0.00125724, + "spread": 0.00332033, + "score": 0.00355038 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00356293, - "spread": 0.0020875, - "score": 0.00412942 + "bias": -0.0040211, + "spread": 0.00133167, + "score": 0.00423587 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00200722, - "spread": 0.0040443, - "score": 0.00451501 + "bias": -0.00181108, + "spread": 0.00464145, + "score": 0.00498227 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00270043, - "spread": 0.00492465, - "score": 0.00561645 + "bias": -0.0040412, + "spread": 0.00448343, + "score": 0.00603593 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00408902, - "spread": 0.00406295, - "score": 0.00576434 + "bias": -0.01398242, + "spread": 0.00526926, + "score": 0.01494233 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.02972944, - "spread": 0.04697175, - "score": 0.05558944 + "bias": -0.02661165, + "spread": 0.04207009, + "score": 0.04978024 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.0030467, - "spread": 0.00227078, - "score": 0.00379984 + "bias": -0.00347973, + "spread": 0.00330458, + "score": 0.00479883 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": -0.0030467, - "spread": 0.00227078, - "score": 0.00379984 + "bias": -0.00347973, + "spread": 0.00330458, + "score": 0.00479883 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.0030467, - "spread": 0.00227078, - "score": 0.00379984 + "bias": -0.00347973, + "spread": 0.00330458, + "score": 0.00479883 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.00387822, - "spread": 0.05405929, - "score": 0.05419822 + "bias": -0.0017349, + "spread": 0.0540645, + "score": 0.05409233 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00379682, - "spread": 0.00882853, - "score": 0.00961035 + "bias": -0.00377748, + "spread": 0.00868545, + "score": 0.00947135 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00201873, - "spread": 0.00857291, - "score": 0.00880739 + "bias": 0.00178885, + "spread": 0.00973989, + "score": 0.0099028 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00108133, - "spread": 0.00187293, - "score": 0.00216267 + "bias": -0.00097698, + "spread": 0.00169218, + "score": 0.00195396 }, { "profile": "cp_0pct", @@ -9709,9 +9709,9 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.00246102, - "spread": 0.0622683, - "score": 0.06231692 + "bias": -0.01059366, + "spread": 0.07542695, + "score": 0.07616725 }, { "profile": "cp_0pct", @@ -9745,90 +9745,90 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00305769, - "spread": 0.0178378, - "score": 0.01809797 + "bias": -0.00048301, + "spread": 0.01565, + "score": 0.01565745 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00503482, - "spread": 0.00478519, - "score": 0.00694604 + "bias": -0.00797879, + "spread": 0.00671819, + "score": 0.0104305 }, { "profile": "cp_0pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00265869, - "spread": 0.00519853, - "score": 0.00583895 + "bias": -0.00281308, + "spread": 0.00377693, + "score": 0.00470942 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "overall", "condition_bin": "overall", - "bias": -0.00120784, - "spread": 0.00160438, - "score": 0.00200821 + "bias": -0.00047359, + "spread": 0.00228687, + "score": 0.00233539 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.01227792, - "spread": 0.02020951, - "score": 0.02364681 + "bias": 0.0142646, + "spread": 0.0159033, + "score": 0.02136337 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00263183, - "spread": 0.0063296, - "score": 0.00685495 + "bias": -0.00099615, + "spread": 0.00490955, + "score": 0.00500959 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00203437, - "spread": 0.00417821, - "score": 0.00464716 + "bias": 0.00263788, + "spread": 0.00565195, + "score": 0.00623722 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00282687, - "spread": 0.00248418, - "score": 0.00376329 + "bias": -0.00274287, + "spread": 0.00229286, + "score": 0.00357499 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00251174, - "spread": 0.00290099, - "score": 0.00383726 + "bias": 0.00038938, + "spread": 0.00501394, + "score": 0.00502904 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00497138, - "spread": 0.0069533, - "score": 0.00854769 + "bias": -0.00254872, + "spread": 0.00506085, + "score": 0.0056664 }, { "profile": "cp_0pct", @@ -9844,117 +9844,117 @@ "campaign_months": 2, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 9.926e-05, - "spread": 0.00118466, - "score": 0.00118881 + "bias": 0.002982, + "spread": 0.0049325, + "score": 0.00576384 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00401615, - "spread": 0.00277899, - "score": 0.00488388 + "bias": -0.00305016, + "spread": 0.00165609, + "score": 0.00347075 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.0022845, - "spread": 0.0034432, - "score": 0.00413214 + "bias": 0.00261025, + "spread": 0.00431179, + "score": 0.00504033 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00264162, - "spread": 0.00562238, - "score": 0.00621203 + "bias": -0.00067208, + "spread": 0.00503917, + "score": 0.00508379 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00596764, - "spread": 0.00762973, - "score": 0.00968636 + "bias": -0.00972702, + "spread": 0.00878859, + "score": 0.01310931 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.02463284, - "spread": 0.07404246, - "score": 0.07803245 + "bias": -0.02321801, + "spread": 0.06569432, + "score": 0.06967654 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.09532865, - "spread": 0.18060757, - "score": 0.20422205 + "bias": 0.10122649, + "spread": 0.18501213, + "score": 0.21089402 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": -0.00584547, - "spread": 0.0076784, - "score": 0.00965025 + "bias": -0.01500706, + "spread": 0.02542614, + "score": 0.02952458 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.00120784, - "spread": 0.00160438, - "score": 0.00200821 + "bias": 2.53492616, + "spread": 4.39129093, + "score": 5.07043259 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.02043367, - "spread": 0.0189057, - "score": 0.02783811 + "bias": -0.0048759, + "spread": 0.0590001, + "score": 0.05920123 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00329246, - "spread": 0.0018614, - "score": 0.00378221 + "bias": -0.00156045, + "spread": 0.00350346, + "score": 0.00383526 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": -0.00110208, - "spread": 0.00339167, - "score": 0.00356623 + "bias": 0.00024089, + "spread": 0.0047614, + "score": 0.00476749 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00232987, - "spread": 0.0028243, - "score": 0.00366127 + "bias": -0.00218245, + "spread": 0.00301954, + "score": 0.00372568 }, { "profile": "cp_0pct", @@ -9979,9 +9979,9 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": 0.0075743, - "spread": 0.05336965, - "score": 0.05390445 + "bias": 0.00529473, + "spread": 0.04309448, + "score": 0.04341852 }, { "profile": "cp_0pct", @@ -10015,90 +10015,90 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00056585, - "spread": 0.01187027, - "score": 0.01188375 + "bias": 0.0031851, + "spread": 0.01096602, + "score": 0.01141922 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00040927, - "spread": 0.00366992, - "score": 0.00369267 + "bias": -0.00178321, + "spread": 0.00550771, + "score": 0.00578919 }, { "profile": "cp_0pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00023926, - "spread": 0.00245137, - "score": 0.00246302 + "bias": 0.00112549, + "spread": 0.00269002, + "score": 0.00291598 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "overall", "condition_bin": "overall", - "bias": 0.00178914, - "spread": 0.00181368, - "score": 0.00254764 + "bias": 0.00098398, + "spread": 0.00213903, + "score": 0.0023545 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00781956, - "spread": 0.01439119, - "score": 0.0163784 + "bias": 0.00945208, + "spread": 0.00975115, + "score": 0.01358038 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00016427, - "spread": 0.00286328, - "score": 0.00286799 + "bias": -0.00014498, + "spread": 0.00331333, + "score": 0.0033165 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00179282, - "spread": 0.00057927, - "score": 0.00188408 + "bias": 0.00081646, + "spread": 0.00179632, + "score": 0.00197317 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -8.798e-05, - "spread": 0.00307999, - "score": 0.00308125 + "bias": -0.00011029, + "spread": 0.00327238, + "score": 0.00327424 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00488096, - "spread": 0.00574674, - "score": 0.00753981 + "bias": 0.00174887, + "spread": 0.00537501, + "score": 0.00565237 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00212701, - "spread": 0.00319602, - "score": 0.0038391 + "bias": 0.00093246, + "spread": 0.00409263, + "score": 0.00419751 }, { "profile": "cp_0pct", @@ -10114,126 +10114,126 @@ "campaign_months": 3, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00142626, - "spread": 0.01027563, - "score": 0.01037414 + "bias": 0.00080435, + "spread": 0.00745328, + "score": 0.00749656 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00031214, - "spread": 0.00251402, - "score": 0.00253332 + "bias": -3.399e-05, + "spread": 0.00222991, + "score": 0.00223016 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00368246, - "spread": 0.00270769, - "score": 0.00457078 + "bias": 0.00253611, + "spread": 0.00281168, + "score": 0.00378648 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00133642, - "spread": 0.00522982, - "score": 0.00539788 + "bias": 0.00049952, + "spread": 0.00382626, + "score": 0.00385873 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00250142, - "spread": 0.00648093, - "score": 0.00694691 + "bias": -0.00363123, + "spread": 0.00803206, + "score": 0.00881475 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00808367, - "spread": 0.03346455, - "score": 0.03442705 + "bias": -0.01937299, + "spread": 0.03129598, + "score": 0.03680695 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.0059818, - "spread": 0.03312889, - "score": 0.0336646 + "bias": 0.00229629, + "spread": 0.05471345, + "score": 0.05476161 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": -0.15431917, - "spread": 0.2716436, - "score": 0.31241743 + "bias": -0.16330016, + "spread": 0.27994369, + "score": 0.32409167 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.00178914, - "spread": 0.00181368, - "score": 0.00254764 + "bias": 0.00098398, + "spread": 0.00213903, + "score": 0.0023545 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.00198679, - "spread": 0.02377948, - "score": 0.02386234 + "bias": -0.0224668, + "spread": 0.02992694, + "score": 0.03742163 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00058739, - "spread": 0.000638, - "score": 0.00086722 + "bias": -0.00039819, + "spread": 0.00209229, + "score": 0.00212984 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00128595, - "spread": 0.00265784, - "score": 0.00295259 + "bias": 0.00131519, + "spread": 0.00294952, + "score": 0.00322946 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00078662, - "spread": 0.00374119, - "score": 0.00382299 + "bias": -0.0001325, + "spread": 0.00394862, + "score": 0.00395085 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": -0.00040369, - "spread": 0.00069922, - "score": 0.00080739 + "bias": -3.384e-05, + "spread": 5.862e-05, + "score": 6.769e-05 }, { "profile": "cp_0pct", @@ -10249,9 +10249,9 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": 0.00913957, - "spread": 0.03598422, - "score": 0.03712675 + "bias": 0.01270209, + "spread": 0.02442607, + "score": 0.02753136 }, { "profile": "cp_0pct", @@ -10285,243 +10285,243 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00067989, - "spread": 0.01283871, - "score": 0.0128567 + "bias": -0.00120564, + "spread": 0.00940093, + "score": 0.00947792 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.0056117, - "spread": 0.00505488, - "score": 0.00755268 + "bias": 0.00197929, + "spread": 0.00489654, + "score": 0.00528145 }, { "profile": "cp_0pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00149004, - "spread": 0.00402495, - "score": 0.0042919 + "bias": 0.00133224, + "spread": 0.0042119, + "score": 0.00441758 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "overall", "condition_bin": "overall", - "bias": 0.00149479, - "spread": 0.00150792, - "score": 0.00212326 + "bias": 0.0013087, + "spread": 0.00148117, + "score": 0.0019765 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.0013837, - "spread": 0.00279903, - "score": 0.00312237 + "bias": 0.0024494, + "spread": 0.00316532, + "score": 0.00400235 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00019516, - "spread": 0.00363531, - "score": 0.00364055 + "bias": 0.00201847, + "spread": 0.0035554, + "score": 0.0040884 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00221858, - "spread": 0.0036135, - "score": 0.00424022 + "bias": 0.00050375, + "spread": 0.00283671, + "score": 0.00288109 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00054374, - "spread": 0.00258253, - "score": 0.00263915 + "bias": 0.00052288, + "spread": 0.00297517, + "score": 0.00302076 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00230793, - "spread": 0.00254739, - "score": 0.0034374 + "bias": 0.00148055, + "spread": 0.00193357, + "score": 0.0024353 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00218646, - "spread": 0.00616613, - "score": 0.0065423 + "bias": 0.00190077, + "spread": 0.00526864, + "score": 0.00560102 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.0022366, - "spread": 0.00184051, - "score": 0.00289653 + "bias": 0.00224881, + "spread": 0.00141982, + "score": 0.00265952 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00035351, - "spread": 0.01132538, - "score": 0.01133089 + "bias": 0.00029007, + "spread": 0.00711186, + "score": 0.00711778 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00031592, - "spread": 0.00160758, - "score": 0.00163833 + "bias": 0.00096695, + "spread": 0.00167456, + "score": 0.00193369 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00295747, - "spread": 0.00132267, - "score": 0.00323977 + "bias": 0.00259391, + "spread": 0.00089963, + "score": 0.00274549 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00061097, - "spread": 0.00222722, - "score": 0.0023095 + "bias": -0.00062627, + "spread": 0.00142213, + "score": 0.00155392 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00137316, - "spread": 0.01026518, - "score": 0.01035662 + "bias": -0.00153197, + "spread": 0.01192094, + "score": 0.01201897 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.00345684, - "spread": 0.02063452, - "score": 0.02092207 + "bias": -0.00510511, + "spread": 0.0141822, + "score": 0.01507306 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.03622114, - "spread": 0.03061605, - "score": 0.04742692 + "bias": -0.04852464, + "spread": 0.03487008, + "score": 0.05975418 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.05402819, - "spread": 0.17329936, - "score": 0.18152607 + "bias": 0.0345608, + "spread": 0.18442657, + "score": 0.18763691 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.00149479, - "spread": 0.00150792, - "score": 0.00212326 + "bias": 0.0013087, + "spread": 0.00148117, + "score": 0.0019765 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.00342309, - "spread": 0.01471553, - "score": 0.01510842 + "bias": -0.01716015, + "spread": 0.02882879, + "score": 0.03354952 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00112113, - "spread": 0.00126619, - "score": 0.00169121 + "bias": 0.0012215, + "spread": 0.00083544, + "score": 0.00147987 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00060485, - "spread": 0.00222229, - "score": 0.00230313 + "bias": 0.00044267, + "spread": 0.00234988, + "score": 0.00239122 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00021221, - "spread": 0.00267239, - "score": 0.0026808 + "bias": -0.00022708, + "spread": 0.00307122, + "score": 0.00307961 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00053106, - "spread": 0.00221394, - "score": 0.00227675 + "bias": 0.00023843, + "spread": 0.0021737, + "score": 0.00218674 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 7.543e-05, - "spread": 0.00013066, - "score": 0.00015087 + "bias": -5.307e-05, + "spread": 9.192e-05, + "score": 0.00010614 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.00835053, - "spread": 0.02793809, - "score": 0.02915936 + "bias": -0.00230665, + "spread": 0.02313889, + "score": 0.02325358 }, { "profile": "cp_0pct", @@ -10555,252 +10555,252 @@ "campaign_months": 6, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00293446, - "spread": 0.00531529, - "score": 0.00607152 + "bias": 0.00281187, + "spread": 0.00447482, + "score": 0.00528495 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00040016, - "spread": 0.00303578, - "score": 0.00306204 + "bias": -8.875e-05, + "spread": 0.0021675, + "score": 0.00216931 }, { "profile": "cp_0pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00271029, - "spread": 0.00515475, - "score": 0.00582384 + "bias": 0.00271679, + "spread": 0.00505288, + "score": 0.00573694 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "overall", "condition_bin": "overall", - "bias": 0.00066441, - "spread": 0.00179039, - "score": 0.0019097 + "bias": 0.0004177, + "spread": 0.00164464, + "score": 0.00169685 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00127162, - "spread": 0.00547184, - "score": 0.00561765 + "bias": 0.00103256, + "spread": 0.00516105, + "score": 0.00526333 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00242004, - "spread": 0.00415236, - "score": 0.00480611 + "bias": -0.00174381, + "spread": 0.00318896, + "score": 0.00363461 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00195454, - "spread": 0.001473, - "score": 0.00244743 + "bias": 0.00170399, + "spread": 0.00174209, + "score": 0.00243689 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00025556, - "spread": 0.00157692, - "score": 0.00159749 + "bias": 6.565e-05, + "spread": 0.00164718, + "score": 0.00164849 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00314629, - "spread": 0.00316308, - "score": 0.00446142 + "bias": 0.00246476, + "spread": 0.00212061, + "score": 0.00325147 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00058748, - "spread": 0.00287394, - "score": 0.00293337 + "bias": -0.00030822, + "spread": 0.00319333, + "score": 0.00320817 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.00129465, - "spread": 0.00163866, - "score": 0.00208838 + "bias": 0.00109738, + "spread": 0.00132612, + "score": 0.00172129 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00197322, - "spread": 0.00433456, - "score": 0.00476256 + "bias": 0.00158562, + "spread": 0.0027034, + "score": 0.0031341 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00086924, - "spread": 0.00165398, - "score": 0.00186848 + "bias": 0.00074615, + "spread": 0.0013856, + "score": 0.00157373 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.0002359, - "spread": 0.00134819, - "score": 0.00136867 + "bias": 7.784e-05, + "spread": 0.00124143, + "score": 0.00124386 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00041122, - "spread": 0.00282112, - "score": 0.00285093 + "bias": 9.367e-05, + "spread": 0.00279401, + "score": 0.00279558 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00526656, - "spread": 0.00740041, - "score": 0.0090831 + "bias": 0.00308555, + "spread": 0.00555145, + "score": 0.00635132 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00392664, - "spread": 0.03010078, - "score": 0.03035581 + "bias": -0.00217387, + "spread": 0.03006329, + "score": 0.03014178 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.01353556, - "spread": 0.020119, - "score": 0.02424841 + "bias": -0.02015126, + "spread": 0.03354036, + "score": 0.03912836 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.03415005, - "spread": 0.23038243, - "score": 0.23289974 + "bias": 0.01405847, + "spread": 0.2237535, + "score": 0.22419471 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.10076944, - "spread": 0.12946093, - "score": 0.16405674 + "bias": -0.05270595, + "spread": 0.07458178, + "score": 0.09132557 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.00684664, - "spread": 0.03874154, - "score": 0.03934188 + "bias": -0.01672556, + "spread": 0.04077655, + "score": 0.04407348 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00027457, - "spread": 0.00248186, - "score": 0.002497 + "bias": -0.0001084, + "spread": 0.00145764, + "score": 0.00146167 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00111796, - "spread": 0.00078667, - "score": 0.001367 + "bias": 0.0009386, + "spread": 0.00097203, + "score": 0.00135123 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00069043, - "spread": 0.00157445, - "score": 0.00171918 + "bias": -0.00081085, + "spread": 0.00165124, + "score": 0.00183958 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00034475, - "spread": 0.00117372, - "score": 0.0012233 + "bias": 0.00014539, + "spread": 0.00130326, + "score": 0.00131135 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": -0.00044035, - "spread": 0.00149148, - "score": 0.00155513 + "bias": -0.00068315, + "spread": 0.0019986, + "score": 0.00211213 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": 0.00184894, - "spread": 0.02169328, - "score": 0.02177193 + "bias": 0.00365358, + "spread": 0.01658812, + "score": 0.01698571 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": -0.00089623, - "spread": 0.0024239, - "score": 0.00258428 + "bias": -0.00171629, + "spread": 0.00313945, + "score": 0.00357796 }, { "profile": "cp_0pct", @@ -10825,90 +10825,90 @@ "campaign_months": 12, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00125256, - "spread": 0.0063314, - "score": 0.00645412 + "bias": 0.00036825, + "spread": 0.00536232, + "score": 0.00537495 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00273301, - "spread": 0.00328756, - "score": 0.00427521 + "bias": 0.00242507, + "spread": 0.00252668, + "score": 0.00350215 }, { "profile": "cp_0pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.0003648, - "spread": 0.00303662, - "score": 0.00305845 + "bias": -0.00031542, + "spread": 0.00301635, + "score": 0.00303279 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "overall", "condition_bin": "overall", - "bias": -0.00275762, - "spread": 0.00206729, - "score": 0.00344647 + "bias": -0.0031911, + "spread": 0.00308461, + "score": 0.00443824 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00745963, - "spread": 0.02333601, - "score": 0.0244993 + "bias": 0.00880668, + "spread": 0.02186376, + "score": 0.02357078 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.0035916, - "spread": 0.0081051, - "score": 0.00886523 + "bias": 0.00422359, + "spread": 0.006589, + "score": 0.00782647 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.0102023, - "spread": 0.01344143, - "score": 0.0168748 + "bias": 0.00598866, + "spread": 0.01370179, + "score": 0.01495336 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00128679, - "spread": 0.00399143, - "score": 0.00419373 + "bias": -0.00142096, + "spread": 0.00423778, + "score": 0.00446966 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00495233, - "spread": 0.00893293, - "score": 0.01021385 + "bias": -0.01108945, + "spread": 0.00781321, + "score": 0.01356548 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00964386, - "spread": 0.00795669, - "score": 0.01250252 + "bias": -0.00711117, + "spread": 0.00715388, + "score": 0.01008696 }, { "profile": "cp_minus_10pct", @@ -10924,117 +10924,117 @@ "campaign_months": 1, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00269194, - "spread": 0.01340285, - "score": 0.01367051 + "bias": 0.00164497, + "spread": 0.01264005, + "score": 0.01274664 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00488245, - "spread": 0.00264728, - "score": 0.00555396 + "bias": -0.00523471, + "spread": 0.00120442, + "score": 0.00537148 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00244785, - "spread": 0.0050895, - "score": 0.00564756 + "bias": -0.00239047, + "spread": 0.00577089, + "score": 0.0062464 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -4.443e-05, - "spread": 0.00485467, - "score": 0.00485487 + "bias": -0.00120217, + "spread": 0.00412424, + "score": 0.00429588 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00191469, - "spread": 0.00636208, - "score": 0.00664395 + "bias": -0.01226886, + "spread": 0.00807525, + "score": 0.01468791 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.00611376, - "spread": 0.06051473, - "score": 0.06082278 + "bias": 0.00987298, + "spread": 0.05461028, + "score": 0.05549557 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.03854336, - "spread": 0.18737337, - "score": 0.19129655 + "bias": -0.03897684, + "spread": 0.18634427, + "score": 0.19037695 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": -1.32165961, - "spread": 2.22277578, - "score": 2.58602326 + "bias": -1.32209309, + "spread": 2.22274937, + "score": 2.58622213 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 1.99753949, - "spread": 3.58508465, - "score": 4.10402193 + "bias": 1.99710601, + "spread": 3.58520778, + "score": 4.10391853 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.00284071, - "spread": 0.05285708, - "score": 0.05293336 + "bias": -0.00030421, + "spread": 0.04626762, + "score": 0.04626862 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00158193, - "spread": 0.00920707, - "score": 0.00934199 + "bias": -0.00265021, + "spread": 0.00880141, + "score": 0.00919176 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00292959, - "spread": 0.01115999, - "score": 0.01153811 + "bias": 0.00266919, + "spread": 0.0124183, + "score": 0.01270192 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00048312, - "spread": 0.00246021, - "score": 0.0025072 + "bias": -0.00050849, + "spread": 0.00250402, + "score": 0.00255513 }, { "profile": "cp_minus_10pct", @@ -11059,9 +11059,9 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": 0.03637749, - "spread": 0.0664106, - "score": 0.07572113 + "bias": 0.0326791, + "spread": 0.05806704, + "score": 0.06663111 }, { "profile": "cp_minus_10pct", @@ -11095,90 +11095,90 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00365186, - "spread": 0.01811648, - "score": 0.01848088 + "bias": -0.00061497, + "spread": 0.01684331, + "score": 0.01685453 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00695033, - "spread": 0.00673623, - "score": 0.00967905 + "bias": -0.00878366, + "spread": 0.00665254, + "score": 0.01101857 }, { "profile": "cp_minus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00186634, - "spread": 0.00445927, - "score": 0.00483408 + "bias": -0.00223437, + "spread": 0.00373146, + "score": 0.00434927 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "overall", "condition_bin": "overall", - "bias": -0.00105375, - "spread": 0.00149002, - "score": 0.00182498 + "bias": -0.00033964, + "spread": 0.00205741, + "score": 0.00208525 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00906196, - "spread": 0.0189908, - "score": 0.02104209 + "bias": 0.01265306, + "spread": 0.01570623, + "score": 0.02016893 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00496391, - "spread": 0.00531038, - "score": 0.00726915 + "bias": 0.0049822, + "spread": 0.00444074, + "score": 0.00667402 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.01131577, - "spread": 0.00597765, - "score": 0.01279762 + "bias": 0.0105801, + "spread": 0.00860368, + "score": 0.01363679 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00644464, - "spread": 0.00306318, - "score": 0.00713558 + "bias": -0.00603326, + "spread": 0.00209697, + "score": 0.00638729 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00066861, - "spread": 0.00299806, - "score": 0.00307171 + "bias": -0.0011397, + "spread": 0.00306405, + "score": 0.00326915 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.0066647, - "spread": 0.00515646, - "score": 0.00842658 + "bias": -0.0041044, + "spread": 0.00420642, + "score": 0.00587708 }, { "profile": "cp_minus_10pct", @@ -11194,117 +11194,117 @@ "campaign_months": 2, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.0096, - "spread": 0.01059594, - "score": 0.01429804 + "bias": 0.01157395, + "spread": 0.00849418, + "score": 0.01435644 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00532454, - "spread": 0.00308198, - "score": 0.00615218 + "bias": -0.00412873, + "spread": 0.00172862, + "score": 0.00447599 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00182486, - "spread": 0.00401927, - "score": 0.00441414 + "bias": 0.0020727, + "spread": 0.00449758, + "score": 0.0049522 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00042795, - "spread": 0.00553356, - "score": 0.00555009 + "bias": 0.00173484, + "spread": 0.00507232, + "score": 0.0053608 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00161622, - "spread": 0.00672922, - "score": 0.00692059 + "bias": -0.00278523, + "spread": 0.00898975, + "score": 0.00941132 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.01491478, - "spread": 0.06120679, - "score": 0.06299779 + "bias": -0.01613918, + "spread": 0.05609129, + "score": 0.058367 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.12098275, - "spread": 0.16224921, - "score": 0.2023898 + "bias": 0.11717335, + "spread": 0.14683477, + "score": 0.18785645 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": -0.17101319, - "spread": 0.20708441, - "score": 0.2685693 + "bias": -0.17619635, + "spread": 0.2013692, + "score": 0.26757187 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.27506285, - "spread": 0.38849595, - "score": 0.47601332 + "bias": 1.56924488, + "spread": 3.29925746, + "score": 3.65344075 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.02611852, - "spread": 0.02501414, - "score": 0.03616469 + "bias": 0.00313045, + "spread": 0.04716952, + "score": 0.04727328 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00084886, - "spread": 0.00349431, - "score": 0.00359593 + "bias": 0.00078014, + "spread": 0.00392104, + "score": 0.0039979 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": -0.00583889, - "spread": 0.0039016, - "score": 0.00702247 + "bias": -0.00463175, + "spread": 0.00438232, + "score": 0.00637635 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.0040228, - "spread": 0.00305335, - "score": 0.00505033 + "bias": -0.00399608, + "spread": 0.00292631, + "score": 0.00495297 }, { "profile": "cp_minus_10pct", @@ -11329,9 +11329,9 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": 0.02478495, - "spread": 0.05130189, - "score": 0.05697524 + "bias": 0.01973879, + "spread": 0.04176173, + "score": 0.04619158 }, { "profile": "cp_minus_10pct", @@ -11365,90 +11365,90 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00066735, - "spread": 0.01177422, - "score": 0.01179312 + "bias": 0.00317638, + "spread": 0.01034256, + "score": 0.01081933 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00087402, - "spread": 0.00393025, - "score": 0.00402626 + "bias": -0.00171121, + "spread": 0.00485836, + "score": 0.00515092 }, { "profile": "cp_minus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00143931, - "spread": 0.00293114, - "score": 0.00326545 + "bias": 0.00220901, + "spread": 0.00196565, + "score": 0.00295694 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "overall", "condition_bin": "overall", - "bias": 0.00174356, - "spread": 0.00172363, - "score": 0.00245171 + "bias": 0.00098527, + "spread": 0.00194045, + "score": 0.00217626 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00821912, - "spread": 0.01406215, - "score": 0.01628797 + "bias": 0.00889651, + "spread": 0.00994786, + "score": 0.01334571 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00793503, - "spread": 0.00146606, - "score": 0.00806932 + "bias": 0.00615937, + "spread": 0.0025278, + "score": 0.0066579 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00945082, - "spread": 0.00314188, - "score": 0.00995939 + "bias": 0.00828162, + "spread": 0.0052295, + "score": 0.00979454 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00326202, - "spread": 0.00283529, - "score": 0.004322 + "bias": -0.00326986, + "spread": 0.00297371, + "score": 0.00441983 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00401121, - "spread": 0.0047678, - "score": 0.00623071 + "bias": 0.00036481, + "spread": 0.00435059, + "score": 0.00436586 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.0012149, - "spread": 0.00417288, - "score": 0.00434613 + "bias": -0.00155517, + "spread": 0.00359743, + "score": 0.00391919 }, { "profile": "cp_minus_10pct", @@ -11464,126 +11464,126 @@ "campaign_months": 3, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00081487, - "spread": 0.00911758, - "score": 0.00915392 + "bias": -0.00120392, + "spread": 0.00541515, + "score": 0.00554736 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00169786, - "spread": 0.00226398, - "score": 0.00282991 + "bias": -0.00094344, + "spread": 0.00186431, + "score": 0.00208944 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00345565, - "spread": 0.0030098, - "score": 0.00458262 + "bias": 0.00229495, + "spread": 0.00356672, + "score": 0.00424126 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00392213, - "spread": 0.00449947, - "score": 0.00596895 + "bias": 0.00258276, + "spread": 0.00385204, + "score": 0.00463777 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.0054608, - "spread": 0.00593606, - "score": 0.00806581 + "bias": -0.00116981, + "spread": 0.00803582, + "score": 0.00812052 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00522875, - "spread": 0.03295288, - "score": 0.03336513 + "bias": -0.01497003, + "spread": 0.03306786, + "score": 0.03629856 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.01729468, - "spread": 0.04035183, - "score": 0.0439019 + "bias": 0.01858797, + "spread": 0.05426956, + "score": 0.05736461 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.15955562, - "spread": 0.27602389, - "score": 0.31882155 + "bias": 0.15895498, + "spread": 0.27544025, + "score": 0.31801575 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.33795177, - "spread": 0.49418743, - "score": 0.59869242 + "bias": -0.33871005, + "spread": 0.49517357, + "score": 0.59993447 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.01447852, - "spread": 0.02007557, - "score": 0.02475189 + "bias": -0.01588328, + "spread": 0.03067637, + "score": 0.03454444 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.0023764, - "spread": 0.00159388, - "score": 0.00286143 + "bias": 0.00194703, + "spread": 0.002644, + "score": 0.00328354 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": -0.0035605, - "spread": 0.00241834, - "score": 0.00430413 + "bias": -0.00256317, + "spread": 0.00234603, + "score": 0.00347472 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00062329, - "spread": 0.00314032, - "score": 0.00320157 + "bias": -0.00150673, + "spread": 0.0040206, + "score": 0.00429366 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": -0.00020581, - "spread": 0.00138311, - "score": 0.00139834 + "bias": 0.00029208, + "spread": 0.0006614, + "score": 0.00072302 }, { "profile": "cp_minus_10pct", @@ -11599,9 +11599,9 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": 0.02751649, - "spread": 0.04277456, - "score": 0.05086079 + "bias": 0.02636358, + "spread": 0.03009909, + "score": 0.04001242 }, { "profile": "cp_minus_10pct", @@ -11635,243 +11635,243 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00097601, - "spread": 0.0119598, - "score": 0.01199956 + "bias": -0.00038741, + "spread": 0.00898452, + "score": 0.00899287 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00462972, - "spread": 0.00446164, - "score": 0.00642966 + "bias": 0.00132026, + "spread": 0.00476282, + "score": 0.00494242 }, { "profile": "cp_minus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00231109, - "spread": 0.00407384, - "score": 0.00468373 + "bias": 0.00198589, + "spread": 0.00331779, + "score": 0.00386672 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "overall", "condition_bin": "overall", - "bias": 0.00150011, - "spread": 0.00141091, - "score": 0.00205937 + "bias": 0.00140993, + "spread": 0.00138501, + "score": 0.0019764 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00084372, - "spread": 0.0030403, - "score": 0.0031552 + "bias": 0.00330189, + "spread": 0.00288283, + "score": 0.00438328 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00804957, - "spread": 0.00180652, - "score": 0.00824979 + "bias": 0.00739474, + "spread": 0.00296229, + "score": 0.00796601 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.01036081, - "spread": 0.00364067, - "score": 0.01098184 + "bias": 0.00886466, + "spread": 0.00239085, + "score": 0.00918141 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00225974, - "spread": 0.00271359, - "score": 0.00353129 + "bias": -0.00220805, + "spread": 0.00268643, + "score": 0.00347741 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00135017, - "spread": 0.00259751, - "score": 0.00292746 + "bias": 0.0004127, + "spread": 0.00218816, + "score": 0.00222674 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00074289, - "spread": 0.0062238, - "score": 0.00626798 + "bias": -0.00037554, + "spread": 0.00576889, + "score": 0.0057811 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.04025334, - "spread": 0.01002506, - "score": 0.04148293 + "bias": 0.0403047, + "spread": 0.00975161, + "score": 0.04146761 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00157502, - "spread": 0.0114258, - "score": 0.01153385 + "bias": 0.00098814, + "spread": 0.00749325, + "score": 0.00755813 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00064853, - "spread": 0.00156348, - "score": 0.00169265 + "bias": 5.951e-05, + "spread": 0.00141417, + "score": 0.00141542 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00281625, - "spread": 0.00102494, - "score": 0.00299696 + "bias": 0.00268643, + "spread": 0.00121362, + "score": 0.00294785 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00133173, - "spread": 0.00193999, - "score": 0.00235309 + "bias": 0.00071321, + "spread": 0.00119013, + "score": 0.00138747 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00505329, - "spread": 0.00891889, - "score": 0.01025096 + "bias": 0.0023213, + "spread": 0.01031748, + "score": 0.01057539 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.00648734, - "spread": 0.01851621, - "score": 0.01961977 + "bias": -0.00103137, + "spread": 0.01234499, + "score": 0.012388 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.018046, - "spread": 0.0340999, - "score": 0.03858058 + "bias": -0.02975462, + "spread": 0.02771802, + "score": 0.0406648 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": -0.01778217, - "spread": 0.28555196, - "score": 0.2861051 + "bias": -0.02717412, + "spread": 0.28237005, + "score": 0.2836746 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.09364203, - "spread": 0.11463957, - "score": 0.14802385 + "bias": -0.09373221, + "spread": 0.11490432, + "score": 0.14828598 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.0074845, - "spread": 0.01491804, - "score": 0.01669029 + "bias": -0.00713772, + "spread": 0.02744859, + "score": 0.02836145 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.0039673, - "spread": 0.00197638, - "score": 0.00443233 + "bias": 0.00391235, + "spread": 0.00131329, + "score": 0.00412689 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": -0.00380749, - "spread": 0.00193924, - "score": 0.00427289 + "bias": -0.00366293, + "spread": 0.00185901, + "score": 0.00410767 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00137492, - "spread": 0.00247626, - "score": 0.00283236 + "bias": -0.00138845, + "spread": 0.00256392, + "score": 0.00291573 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 5.42e-05, - "spread": 0.002173, - "score": 0.00217367 + "bias": 0.00011775, + "spread": 0.00187775, + "score": 0.00188144 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 0.00167374, - "spread": 0.00205042, - "score": 0.00264682 + "bias": 0.00165474, + "spread": 0.00205174, + "score": 0.00263587 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": 0.02936824, - "spread": 0.04718324, - "score": 0.05557654 + "bias": 0.03347413, + "spread": 0.05020782, + "score": 0.06034354 }, { "profile": "cp_minus_10pct", @@ -11905,252 +11905,252 @@ "campaign_months": 6, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00297187, - "spread": 0.00477096, - "score": 0.00562086 + "bias": 0.0026132, + "spread": 0.00366867, + "score": 0.00450421 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 1.41e-05, - "spread": 0.00284851, - "score": 0.00284855 + "bias": 8.051e-05, + "spread": 0.00185911, + "score": 0.00186085 }, { "profile": "cp_minus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00348023, - "spread": 0.00497459, - "score": 0.00607112 + "bias": 0.00324719, + "spread": 0.00481192, + "score": 0.00580507 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "overall", "condition_bin": "overall", - "bias": 0.00070416, - "spread": 0.00169026, - "score": 0.00183107 + "bias": 0.00041655, + "spread": 0.00170294, + "score": 0.00175315 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00114147, - "spread": 0.00539885, - "score": 0.0055182 + "bias": 0.00101906, + "spread": 0.00399678, + "score": 0.00412465 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00427956, - "spread": 0.00350992, - "score": 0.00553481 + "bias": 0.00330387, + "spread": 0.00338235, + "score": 0.00472819 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.01085842, - "spread": 0.00214447, - "score": 0.01106815 + "bias": 0.00931899, + "spread": 0.0021436, + "score": 0.00956236 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00213027, - "spread": 0.00148185, - "score": 0.00259498 + "bias": -0.00235602, + "spread": 0.00176638, + "score": 0.00294464 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00201281, - "spread": 0.00252605, - "score": 0.00322991 + "bias": 0.00061943, + "spread": 0.00172949, + "score": 0.00183707 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00169832, - "spread": 0.00272045, - "score": 0.00320704 + "bias": -0.00173092, + "spread": 0.00331788, + "score": 0.00374225 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.01368949, - "spread": 0.05131833, - "score": 0.05311283 + "bias": 0.01352875, + "spread": 0.05120805, + "score": 0.052965 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00108165, - "spread": 0.00485567, - "score": 0.00497469 + "bias": 0.00172442, + "spread": 0.00341577, + "score": 0.00382637 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -9.149e-05, - "spread": 0.00150045, - "score": 0.00150324 + "bias": 3.051e-05, + "spread": 0.00134456, + "score": 0.00134491 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00026412, - "spread": 0.00132826, - "score": 0.00135427 + "bias": -1.384e-05, + "spread": 0.00156977, + "score": 0.00156983 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00233635, - "spread": 0.00258497, - "score": 0.00348434 + "bias": 0.00136624, + "spread": 0.00252583, + "score": 0.00287166 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00887722, - "spread": 0.00749427, - "score": 0.01161762 + "bias": 0.00603143, + "spread": 0.00560028, + "score": 0.00823051 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00028339, - "spread": 0.02497055, - "score": 0.02497216 + "bias": 0.00224529, + "spread": 0.0266734, + "score": 0.02676774 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.00516336, - "spread": 0.02020941, - "score": 0.02085859 + "bias": -0.01485872, + "spread": 0.02777304, + "score": 0.03149799 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.03431905, - "spread": 0.20122926, - "score": 0.20413479 + "bias": 0.01910173, + "spread": 0.18471349, + "score": 0.18569854 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.58653318, - "spread": 0.54917964, - "score": 0.80350448 + "bias": -0.5720501, + "spread": 0.56341211, + "score": 0.80291626 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.0019558, - "spread": 0.04354781, - "score": 0.0435917 + "bias": -0.01518286, + "spread": 0.04672329, + "score": 0.04912826 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00296776, - "spread": 0.00195653, - "score": 0.00355466 + "bias": 0.00284066, + "spread": 0.001262, + "score": 0.00310838 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": -0.00293869, - "spread": 0.00084934, - "score": 0.00305897 + "bias": -0.00258874, + "spread": 0.0010269, + "score": 0.00278498 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00215333, - "spread": 0.0014599, - "score": 0.00260156 + "bias": -0.00192339, + "spread": 0.00180496, + "score": 0.00263767 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": -0.00015025, - "spread": 0.00125897, - "score": 0.0012679 + "bias": -0.00041481, + "spread": 0.00158035, + "score": 0.00163388 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 0.00091609, - "spread": 0.00028754, - "score": 0.00096016 + "bias": 0.00093557, + "spread": 0.00031575, + "score": 0.00098741 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": 0.02584165, - "spread": 0.03805987, - "score": 0.04600375 + "bias": 0.03017322, + "spread": 0.04106002, + "score": 0.05095438 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": 0.00370367, - "spread": 0.00388038, - "score": 0.00536419 + "bias": 0.00290538, + "spread": 0.00474299, + "score": 0.00556213 }, { "profile": "cp_minus_10pct", @@ -12175,90 +12175,90 @@ "campaign_months": 12, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00194596, - "spread": 0.00528781, - "score": 0.00563451 + "bias": 0.00064393, + "spread": 0.00484232, + "score": 0.00488495 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00213594, - "spread": 0.00333883, - "score": 0.00396359 + "bias": 0.00192245, + "spread": 0.00256843, + "score": 0.00320822 }, { "profile": "cp_minus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00140791, - "spread": 0.00295373, - "score": 0.00327211 + "bias": 0.00026771, + "spread": 0.00295816, + "score": 0.00297025 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "overall", "condition_bin": "overall", - "bias": -0.00333687, - "spread": 0.0024731, - "score": 0.00415342 + "bias": -0.00377482, + "spread": 0.0033638, + "score": 0.00505613 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.01536163, - "spread": 0.0289977, - "score": 0.03281533 + "bias": 0.01573924, + "spread": 0.03329658, + "score": 0.03682914 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00656457, - "spread": 0.00820794, - "score": 0.01051017 + "bias": -0.00515672, + "spread": 0.00679604, + "score": 0.00853099 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00141431, - "spread": 0.01155107, - "score": 0.01163733 + "bias": -0.00082536, + "spread": 0.00915365, + "score": 0.00919078 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00053613, - "spread": 0.00477663, - "score": 0.00480662 + "bias": 0.00045583, + "spread": 0.00498501, + "score": 0.0050058 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00449718, - "spread": 0.00932383, - "score": 0.01035174 + "bias": -0.00980859, + "spread": 0.01268952, + "score": 0.01603847 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.01111534, - "spread": 0.00903104, - "score": 0.01432168 + "bias": -0.00867164, + "spread": 0.00868854, + "score": 0.01227551 }, { "profile": "cp_plus_10pct", @@ -12274,117 +12274,117 @@ "campaign_months": 1, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00778187, - "spread": 0.01521853, - "score": 0.01709272 + "bias": -0.0026248, + "spread": 0.01701527, + "score": 0.01721653 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00250916, - "spread": 0.00312471, - "score": 0.00400745 + "bias": -0.0028659, + "spread": 0.00215348, + "score": 0.00358481 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00144231, - "spread": 0.00304453, - "score": 0.00336889 + "bias": -0.00153249, + "spread": 0.00345888, + "score": 0.00378317 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00590612, - "spread": 0.00454287, - "score": 0.00745117 + "bias": -0.006889, + "spread": 0.00514135, + "score": 0.00859603 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00750551, - "spread": 0.0030487, - "score": 0.00810106 + "bias": -0.01394208, + "spread": 0.00561248, + "score": 0.01502936 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.06628005, - "spread": 0.03676379, - "score": 0.07579328 + "bias": -0.06204913, + "spread": 0.03017508, + "score": 0.06899732 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.03244887, - "spread": 0.19152239, - "score": 0.19425178 + "bias": 0.03201092, + "spread": 0.19244627, + "score": 0.19509041 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 1.31556511, - "spread": 2.22002001, - "score": 2.58054266 + "bias": 1.31512716, + "spread": 2.2203954, + "score": 2.5806424 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -2.00363399, - "spread": 3.58505801, - "score": 4.10696848 + "bias": -2.00407193, + "spread": 3.58490107, + "score": 4.10704517 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.01402062, - "spread": 0.06619859, - "score": 0.06766706 + "bias": -0.00620259, + "spread": 0.05702466, + "score": 0.057361 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.0052947, - "spread": 0.00926279, - "score": 0.01066926 + "bias": -0.00598707, + "spread": 0.00845635, + "score": 0.01036122 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00075103, - "spread": 0.01406488, - "score": 0.01408492 + "bias": 0.00036942, + "spread": 0.01412435, + "score": 0.01412918 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00140086, - "spread": 0.00083462, - "score": 0.00163064 + "bias": -0.00146526, + "spread": 0.00094337, + "score": 0.00174268 }, { "profile": "cp_plus_10pct", @@ -12409,9 +12409,9 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.03946131, - "spread": 0.08612877, - "score": 0.09473838 + "bias": -0.04230128, + "spread": 0.11234962, + "score": 0.1200493 }, { "profile": "cp_plus_10pct", @@ -12445,90 +12445,90 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00252327, - "spread": 0.01674188, - "score": 0.01693096 + "bias": 0.00235597, + "spread": 0.01635567, + "score": 0.01652448 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.0044257, - "spread": 0.00325574, - "score": 0.00549424 + "bias": -0.00746717, + "spread": 0.00569894, + "score": 0.00939343 }, { "profile": "cp_plus_10pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00287471, - "spread": 0.00613526, - "score": 0.00677535 + "bias": -0.00264453, + "spread": 0.00438195, + "score": 0.00511811 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "overall", "condition_bin": "overall", - "bias": -0.00136209, - "spread": 0.00171977, - "score": 0.00219383 + "bias": -0.00043735, + "spread": 0.00236297, + "score": 0.0024031 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00912097, - "spread": 0.02077693, - "score": 0.02269081 + "bias": 0.01386958, + "spread": 0.01751851, + "score": 0.0223442 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00888611, - "spread": 0.0080336, - "score": 0.01197922 + "bias": -0.00398829, + "spread": 0.00754267, + "score": 0.00853219 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00027021, - "spread": 0.00299695, - "score": 0.00300911 + "bias": 0.00023268, + "spread": 0.00340381, + "score": 0.00341175 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00162122, - "spread": 0.00258974, - "score": 0.00305533 + "bias": -0.00104851, + "spread": 0.00305882, + "score": 0.00323354 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00036558, - "spread": 0.00379904, - "score": 0.00381659 + "bias": -0.00181223, + "spread": 0.00632629, + "score": 0.00658074 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.0039065, - "spread": 0.00522269, - "score": 0.00652206 + "bias": -0.00257385, + "spread": 0.00519102, + "score": 0.00579408 }, { "profile": "cp_plus_10pct", @@ -12544,117 +12544,117 @@ "campaign_months": 2, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00828745, - "spread": 0.01420377, - "score": 0.01644473 + "bias": -0.00566416, + "spread": 0.01704639, + "score": 0.0179628 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00233707, - "spread": 0.0026838, - "score": 0.00355875 + "bias": -0.00121665, + "spread": 0.00158129, + "score": 0.00199517 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00242433, - "spread": 0.00303469, - "score": 0.00388416 + "bias": 0.00290205, + "spread": 0.00392431, + "score": 0.00488079 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00595461, - "spread": 0.0052024, - "score": 0.00790711 + "bias": -0.00410656, + "spread": 0.00439936, + "score": 0.00601816 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00986793, - "spread": 0.00917085, - "score": 0.01347147 + "bias": -0.01296275, + "spread": 0.01004227, + "score": 0.01639756 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.03416593, - "spread": 0.08574239, - "score": 0.0922988 + "bias": -0.02994995, + "spread": 0.07713792, + "score": 0.08274816 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.07164115, - "spread": 0.2088393, - "score": 0.22078566 + "bias": 0.08248224, + "spread": 0.21121704, + "score": 0.22675087 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.15863312, - "spread": 0.21690375, - "score": 0.26872235 + "bias": 0.15113248, + "spread": 0.22618486, + "score": 0.27203055 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.27264701, - "spread": 0.38856537, - "score": 0.47467824 + "bias": 2.95031172, + "spread": 4.56751285, + "score": 5.4375098 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.01756146, - "spread": 0.02611529, - "score": 0.03147083 + "bias": -0.01340177, + "spread": 0.0530781, + "score": 0.05474388 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00620892, - "spread": 0.00152961, - "score": 0.00639456 + "bias": -0.00373457, + "spread": 0.0035757, + "score": 0.00517036 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00358933, - "spread": 0.00371385, - "score": 0.00516488 + "bias": 0.0046261, + "spread": 0.0048407, + "score": 0.00669576 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00052363, - "spread": 0.00289434, - "score": 0.00294133 + "bias": -0.00043262, + "spread": 0.00328083, + "score": 0.00330923 }, { "profile": "cp_plus_10pct", @@ -12679,9 +12679,9 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.00797098, - "spread": 0.05877844, - "score": 0.05931645 + "bias": -0.00297442, + "spread": 0.05198386, + "score": 0.05206888 }, { "profile": "cp_plus_10pct", @@ -12715,90 +12715,90 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00144964, - "spread": 0.01367939, - "score": 0.01375599 + "bias": 0.00371665, + "spread": 0.01295278, + "score": 0.01347546 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00032951, - "spread": 0.00411166, - "score": 0.00412485 + "bias": -0.00188823, + "spread": 0.00539074, + "score": 0.00571187 }, { "profile": "cp_plus_10pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00091343, - "spread": 0.00267181, - "score": 0.00282363 + "bias": 0.0003153, + "spread": 0.0032231, + "score": 0.00323849 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "overall", "condition_bin": "overall", - "bias": 0.00183546, - "spread": 0.00190493, - "score": 0.00264531 + "bias": 0.00096289, + "spread": 0.00215264, + "score": 0.00235818 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00483991, - "spread": 0.0140561, - "score": 0.01486603 + "bias": 0.00897324, + "spread": 0.01106467, + "score": 0.01424592 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00621022, - "spread": 0.00426364, - "score": 0.00753296 + "bias": -0.00409612, + "spread": 0.00338076, + "score": 0.0053111 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": -0.00039899, - "spread": 0.00132837, - "score": 0.001387 + "bias": -9.97e-05, + "spread": 0.00135868, + "score": 0.00136233 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00124666, - "spread": 0.00262472, - "score": 0.00290574 + "bias": 0.00074585, + "spread": 0.00307599, + "score": 0.00316512 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00366217, - "spread": 0.00756357, - "score": 0.00840352 + "bias": 0.00063685, + "spread": 0.00725113, + "score": 0.00727904 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00251936, - "spread": 0.00412775, - "score": 0.00483586 + "bias": 0.00012323, + "spread": 0.00319674, + "score": 0.00319911 }, { "profile": "cp_plus_10pct", @@ -12814,126 +12814,126 @@ "campaign_months": 3, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.0038951, - "spread": 0.01140621, - "score": 0.01205294 + "bias": 0.00309842, + "spread": 0.0092541, + "score": 0.00975903 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00088597, - "spread": 0.00297391, - "score": 0.00310308 + "bias": 0.00111922, + "spread": 0.00222913, + "score": 0.00249433 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00380193, - "spread": 0.00273862, - "score": 0.00468559 + "bias": 0.00292087, + "spread": 0.0023571, + "score": 0.00375332 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00066211, - "spread": 0.00559378, - "score": 0.00563283 + "bias": -0.00223357, + "spread": 0.00348391, + "score": 0.00413841 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00033382, - "spread": 0.00625215, - "score": 0.00626106 + "bias": -0.00930119, + "spread": 0.00868783, + "score": 0.01272755 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00968845, - "spread": 0.0343033, - "score": 0.03564523 + "bias": -0.02355348, + "spread": 0.03355884, + "score": 0.04099954 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.00529893, - "spread": 0.03435914, - "score": 0.03476534 + "bias": -0.01313858, + "spread": 0.04948696, + "score": 0.05120138 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": -0.50978738, - "spread": 0.878815, - "score": 1.01597194 + "bias": -0.51477203, + "spread": 0.88613959, + "score": 1.02480907 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.13090984, - "spread": 0.14646347, - "score": 0.19644066 + "bias": 0.13400584, + "spread": 0.15131382, + "score": 0.20212233 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.00317597, - "spread": 0.03158858, - "score": 0.03174783 + "bias": -0.04011234, + "spread": 0.03657242, + "score": 0.05428205 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00337781, - "spread": 0.00121442, - "score": 0.00358948 + "bias": -0.00326341, + "spread": 0.00138105, + "score": 0.0035436 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00592268, - "spread": 0.00313242, - "score": 0.00670001 + "bias": 0.00535157, + "spread": 0.00370561, + "score": 0.00650929 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00253987, - "spread": 0.0035502, - "score": 0.00436519 + "bias": 0.00116924, + "spread": 0.00394537, + "score": 0.00411498 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": -0.00061793, - "spread": 0.00052631, - "score": 0.00081169 + "bias": -0.00045378, + "spread": 0.00052745, + "score": 0.00069579 }, { "profile": "cp_plus_10pct", @@ -12949,9 +12949,9 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.00843796, - "spread": 0.03952632, - "score": 0.04041694 + "bias": -0.00205833, + "spread": 0.02817104, + "score": 0.02824614 }, { "profile": "cp_plus_10pct", @@ -12985,243 +12985,243 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00048842, - "spread": 0.01334919, - "score": 0.01335812 + "bias": -0.00156558, + "spread": 0.0101211, + "score": 0.01024147 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00691795, - "spread": 0.0055751, - "score": 0.00888481 + "bias": 0.00305961, + "spread": 0.00513033, + "score": 0.0059734 }, { "profile": "cp_plus_10pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00012117, - "spread": 0.00418388, - "score": 0.00418563 + "bias": 0.00018602, + "spread": 0.00380961, + "score": 0.00381415 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "overall", "condition_bin": "overall", - "bias": 0.00148947, - "spread": 0.00160507, - "score": 0.00218969 + "bias": 0.00134477, + "spread": 0.00152731, + "score": 0.00203497 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.0006485, - "spread": 0.00240466, - "score": 0.00249057 + "bias": -2.655e-05, + "spread": 0.00482468, + "score": 0.00482475 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00389861, - "spread": 0.00537753, - "score": 0.00664207 + "bias": -0.00082664, + "spread": 0.00408855, + "score": 0.00417128 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00016891, - "spread": 0.00272308, - "score": 0.00272831 + "bias": -0.00064407, + "spread": 0.00315778, + "score": 0.00322279 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00139385, - "spread": 0.00263102, - "score": 0.00297743 + "bias": 0.00111141, + "spread": 0.00329426, + "score": 0.00347669 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00042662, - "spread": 0.00279452, - "score": 0.00282689 + "bias": -0.00030675, + "spread": 0.00176504, + "score": 0.0017915 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00209317, - "spread": 0.00671154, - "score": 0.00703037 + "bias": 0.00057701, + "spread": 0.00760201, + "score": 0.00762388 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.03578014, - "spread": 0.00634404, - "score": 0.03633821 + "bias": -0.0357595, + "spread": 0.00679082, + "score": 0.03639858 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00090917, - "spread": 0.01078805, - "score": 0.01082629 + "bias": -0.00105825, + "spread": 0.00702222, + "score": 0.00710152 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00149652, - "spread": 0.00178, - "score": 0.00232551 + "bias": 0.0017197, + "spread": 0.00178559, + "score": 0.00247905 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00287734, - "spread": 0.00211244, - "score": 0.00356952 + "bias": 0.00277804, + "spread": 0.00140345, + "score": 0.00311242 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00234408, - "spread": 0.00262644, - "score": 0.00352036 + "bias": -0.00204962, + "spread": 0.00166553, + "score": 0.00264101 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.0006818, - "spread": 0.00947384, - "score": 0.00949835 + "bias": -0.00393661, + "spread": 0.01117718, + "score": 0.01185015 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00033214, - "spread": 0.02365879, - "score": 0.02366112 + "bias": -0.00878166, + "spread": 0.01427545, + "score": 0.01676025 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.05299862, - "spread": 0.02907164, - "score": 0.06044844 + "bias": -0.06598936, + "spread": 0.03120684, + "score": 0.07299632 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.11595501, - "spread": 0.35105664, - "score": 0.36971114 + "bias": 0.10490345, + "spread": 0.37085808, + "score": 0.38540946 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.0966316, - "spread": 0.11455271, - "score": 0.14986657 + "bias": 0.09648691, + "spread": 0.11421258, + "score": 0.14951334 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.00245347, - "spread": 0.01781811, - "score": 0.01798623 + "bias": -0.01239775, + "spread": 0.03869486, + "score": 0.04063245 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00211762, - "spread": 0.00120091, - "score": 0.00243444 + "bias": -0.00198839, + "spread": 0.00011106, + "score": 0.00199149 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00521216, - "spread": 0.00238214, - "score": 0.00573072 + "bias": 0.00427932, + "spread": 0.00266266, + "score": 0.00504008 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00163669, - "spread": 0.00286831, - "score": 0.00330242 + "bias": 0.00101608, + "spread": 0.0031105, + "score": 0.00327226 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00092141, - "spread": 0.00233465, - "score": 0.0025099 + "bias": 0.00034493, + "spread": 0.00232139, + "score": 0.00234688 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": -0.0016017, - "spread": 0.00205821, - "score": 0.002608 + "bias": -0.00172771, + "spread": 0.00204955, + "score": 0.00268061 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.04107019, - "spread": 0.04617055, - "score": 0.06179385 + "bias": -0.03587989, + "spread": 0.03928594, + "score": 0.05320481 }, { "profile": "cp_plus_10pct", @@ -13255,252 +13255,252 @@ "campaign_months": 6, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00241317, - "spread": 0.00592787, - "score": 0.00640024 + "bias": 0.00269809, + "spread": 0.00457454, + "score": 0.00531095 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00102805, - "spread": 0.00297736, - "score": 0.00314985 + "bias": 0.00057846, + "spread": 0.00252444, + "score": 0.00258986 }, { "profile": "cp_plus_10pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00171104, - "spread": 0.00550972, - "score": 0.00576929 + "bias": 0.00242717, + "spread": 0.00546924, + "score": 0.00598362 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "overall", "condition_bin": "overall", - "bias": 0.00062469, - "spread": 0.00189072, - "score": 0.00199124 + "bias": 0.00038951, + "spread": 0.00171634, + "score": 0.00175998 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.00111969, - "spread": 0.00624481, - "score": 0.00634439 + "bias": -0.00104145, + "spread": 0.0055009, + "score": 0.00559862 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.0068226, - "spread": 0.0034169, - "score": 0.00763041 + "bias": -0.0046774, + "spread": 0.00314489, + "score": 0.00563635 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": -0.0005189, - "spread": 0.00184486, - "score": 0.00191644 + "bias": 0.00055962, + "spread": 0.00165354, + "score": 0.00174567 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00068179, - "spread": 0.001395, - "score": 0.0015527 + "bias": 0.00032137, + "spread": 0.00139876, + "score": 0.0014352 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00103044, - "spread": 0.00360106, - "score": 0.00374559 + "bias": 0.00086111, + "spread": 0.00245719, + "score": 0.00260371 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00013382, - "spread": 0.00353334, - "score": 0.00353587 + "bias": -0.00150446, + "spread": 0.00418578, + "score": 0.00444794 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.01110019, - "spread": 0.04924013, - "score": 0.05047578 + "bias": -0.01131137, + "spread": 0.04938105, + "score": 0.05065999 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00214417, - "spread": 0.00412826, - "score": 0.00465188 + "bias": 0.00236564, + "spread": 0.00161375, + "score": 0.00286365 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00175795, - "spread": 0.00179021, - "score": 0.00250903 + "bias": 0.0014696, + "spread": 0.0016409, + "score": 0.00220279 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00026284, - "spread": 0.00167903, - "score": 0.00169948 + "bias": 7.054e-05, + "spread": 0.00143567, + "score": 0.0014374 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00150078, - "spread": 0.00350855, - "score": 0.00381605 + "bias": -0.0014934, + "spread": 0.00328359, + "score": 0.00360724 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00166435, - "spread": 0.00738132, - "score": 0.00756664 + "bias": 2.672e-05, + "spread": 0.00551055, + "score": 0.00551061 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.01027979, - "spread": 0.03426852, - "score": 0.03577717 + "bias": -0.00429414, + "spread": 0.03483584, + "score": 0.03509951 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.02132593, - "spread": 0.02334229, - "score": 0.03161737 + "bias": -0.02334428, + "spread": 0.03686516, + "score": 0.04363479 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.02132215, - "spread": 0.26300201, - "score": 0.26386491 + "bias": 0.01524752, + "spread": 0.25313404, + "score": 0.25359284 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.5798823, - "spread": 0.67024434, - "score": 0.88627927 + "bias": 0.62118694, + "spread": 0.69266245, + "score": 0.93040555 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.0147072, - "spread": 0.04312157, - "score": 0.04556063 + "bias": -0.0180415, + "spread": 0.04329518, + "score": 0.04690382 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00377844, - "spread": 0.00289765, - "score": 0.00476161 + "bias": -0.00339421, + "spread": 0.00170464, + "score": 0.00379822 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00512689, - "spread": 0.00101546, - "score": 0.00522648 + "bias": 0.00428585, + "spread": 0.00126158, + "score": 0.00446767 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00049162, - "spread": 0.00150669, - "score": 0.00158487 + "bias": 2.529e-05, + "spread": 0.00158976, + "score": 0.00158996 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00073238, - "spread": 0.00113368, - "score": 0.00134967 + "bias": 0.00025321, + "spread": 0.00117371, + "score": 0.00120071 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": -0.00213095, - "spread": 0.0030892, - "score": 0.00375288 + "bias": -0.00223979, + "spread": 0.00333783, + "score": 0.00401967 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.02537676, - "spread": 0.02167905, - "score": 0.03337605 + "bias": -0.02462055, + "spread": 0.01716022, + "score": 0.03001074 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": -0.00541189, - "spread": 0.00432498, - "score": 0.00692777 + "bias": -0.00622926, + "spread": 0.00450354, + "score": 0.00768671 }, { "profile": "cp_plus_10pct", @@ -13525,90 +13525,90 @@ "campaign_months": 12, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00108328, - "spread": 0.00685938, - "score": 0.0069444 + "bias": 0.00109103, + "spread": 0.00591977, + "score": 0.00601947 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00320891, - "spread": 0.00337448, - "score": 0.00465663 + "bias": 0.00312618, + "spread": 0.00286429, + "score": 0.00423995 }, { "profile": "cp_plus_10pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00078013, - "spread": 0.00283918, - "score": 0.00294441 + "bias": -0.00110556, + "spread": 0.00303525, + "score": 0.00323033 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "overall", "condition_bin": "overall", - "bias": -0.00313355, - "spread": 0.00233166, - "score": 0.00390586 + "bias": -0.00350068, + "spread": 0.00333899, + "score": 0.00483773 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.0146248, - "spread": 0.02683313, - "score": 0.03055981 + "bias": 0.01298297, + "spread": 0.0288263, + "score": 0.03161508 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00186223, - "spread": 0.00979943, - "score": 0.0099748 + "bias": -0.00302407, + "spread": 0.00684109, + "score": 0.00747968 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00089199, - "spread": 0.01003881, - "score": 0.01007836 + "bias": -0.00052459, + "spread": 0.01105125, + "score": 0.0110637 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00047829, - "spread": 0.00426014, - "score": 0.00428691 + "bias": 0.00029893, + "spread": 0.00375971, + "score": 0.00377157 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00431282, - "spread": 0.00880221, - "score": 0.009802 + "bias": -0.00864031, + "spread": 0.01196672, + "score": 0.01475999 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00984242, - "spread": 0.01037075, - "score": 0.01429775 + "bias": -0.00671691, + "spread": 0.00787147, + "score": 0.01034779 }, { "profile": "cp_plus_3pct", @@ -13624,117 +13624,117 @@ "campaign_months": 1, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00595354, - "spread": 0.00583936, - "score": 0.00833923 + "bias": -0.00101023, + "spread": 0.00710916, + "score": 0.00718058 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.0032031, - "spread": 0.0023631, - "score": 0.00398046 + "bias": -0.00360814, + "spread": 0.00156592, + "score": 0.00393329 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00188656, - "spread": 0.00381876, - "score": 0.00425934 + "bias": -0.00180613, + "spread": 0.00420667, + "score": 0.00457801 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00365827, - "spread": 0.00484998, - "score": 0.00607497 + "bias": -0.00442894, + "spread": 0.00508223, + "score": 0.00674126 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00497894, - "spread": 0.00324992, - "score": 0.00594575 + "bias": -0.01375312, + "spread": 0.00618997, + "score": 0.01508191 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.04107508, - "spread": 0.04426963, - "score": 0.06039009 + "bias": -0.03629343, + "spread": 0.03664205, + "score": 0.05157376 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.00760218, - "spread": 0.05891622, - "score": 0.05940467 + "bias": 0.00723504, + "spread": 0.05996447, + "score": 0.06039937 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.39253705, - "spread": 0.66504363, - "score": 0.7722489 + "bias": 0.39216992, + "spread": 0.66530512, + "score": 0.7722876 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.60322268, - "spread": 1.0755106, - "score": 1.23312637 + "bias": -0.60358981, + "spread": 1.0754179, + "score": 1.23322517 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.01201291, - "spread": 0.06820977, - "score": 0.06925953 + "bias": -0.01032208, + "spread": 0.05080866, + "score": 0.05184655 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00432734, - "spread": 0.00871078, - "score": 0.00972644 + "bias": -0.00457418, + "spread": 0.00872857, + "score": 0.00985449 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00187661, - "spread": 0.00974741, - "score": 0.00992641 + "bias": 0.00134589, + "spread": 0.01008656, + "score": 0.01017596 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00123769, - "spread": 0.00166015, - "score": 0.00207074 + "bias": -0.00108743, + "spread": 0.00140009, + "score": 0.00177278 }, { "profile": "cp_plus_3pct", @@ -13759,9 +13759,9 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.01160818, - "spread": 0.06112455, - "score": 0.06221704 + "bias": -0.01755028, + "spread": 0.09545241, + "score": 0.09705243 }, { "profile": "cp_plus_3pct", @@ -13795,90 +13795,90 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00241164, - "spread": 0.01846956, - "score": 0.01862634 + "bias": 0.00105904, + "spread": 0.01684738, + "score": 0.01688063 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00516041, - "spread": 0.00425103, - "score": 0.00668589 + "bias": -0.00799716, + "spread": 0.0064401, + "score": 0.01026788 }, { "profile": "cp_plus_3pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00259278, - "spread": 0.00577687, - "score": 0.00633204 + "bias": -0.00257603, + "spread": 0.00409564, + "score": 0.00483841 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "overall", "condition_bin": "overall", - "bias": -0.00125412, - "spread": 0.00163889, - "score": 0.00206367 + "bias": -0.0004649, + "spread": 0.00228065, + "score": 0.00232755 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.01092687, - "spread": 0.02017354, - "score": 0.02294272 + "bias": 0.01270594, + "spread": 0.01577165, + "score": 0.02025305 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00496243, - "spread": 0.00541533, - "score": 0.00734517 + "bias": -0.00252704, + "spread": 0.00532939, + "score": 0.00589816 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00129276, - "spread": 0.00398678, - "score": 0.00419114 + "bias": 0.00186946, + "spread": 0.0043665, + "score": 0.00474986 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00292022, - "spread": 0.00329616, - "score": 0.00440368 + "bias": -0.00200107, + "spread": 0.00295493, + "score": 0.00356874 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00228452, - "spread": 0.00289076, - "score": 0.0036845 + "bias": -0.00014414, + "spread": 0.00462704, + "score": 0.00462929 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00385383, - "spread": 0.00680866, - "score": 0.00782367 + "bias": -0.00184199, + "spread": 0.00504011, + "score": 0.00536615 }, { "profile": "cp_plus_3pct", @@ -13894,117 +13894,117 @@ "campaign_months": 2, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00270556, - "spread": 0.00461371, - "score": 0.00534849 + "bias": 0.000381, + "spread": 0.00878041, + "score": 0.00878868 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00354281, - "spread": 0.00284035, - "score": 0.00454083 + "bias": -0.00234406, + "spread": 0.00128787, + "score": 0.00267456 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00236789, - "spread": 0.00354185, - "score": 0.00426047 + "bias": 0.00268709, + "spread": 0.00408197, + "score": 0.00488702 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00363501, - "spread": 0.00499944, - "score": 0.00618123 + "bias": -0.00186282, + "spread": 0.00459078, + "score": 0.00495432 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00663927, - "spread": 0.00750441, - "score": 0.01001978 + "bias": -0.0114044, + "spread": 0.01037993, + "score": 0.01542087 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.0298837, - "spread": 0.07744879, - "score": 0.08301415 + "bias": -0.02739284, + "spread": 0.06952462, + "score": 0.07472644 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.07933683, - "spread": 0.17786849, - "score": 0.19476019 + "bias": 0.08594515, + "spread": 0.18011454, + "score": 0.19956908 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.04352682, - "spread": 0.06848816, - "score": 0.08114932 + "bias": 0.03572244, + "spread": 0.07944106, + "score": 0.08710324 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.08094861, - "spread": 0.11660392, - "score": 0.14194771 + "bias": 2.70570075, + "spread": 4.52074193, + "score": 5.26857895 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.02130705, - "spread": 0.01966394, - "score": 0.02899415 + "bias": -0.00654404, + "spread": 0.05834524, + "score": 0.05871108 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00409472, - "spread": 0.00175895, - "score": 0.00445652 + "bias": -0.00184804, + "spread": 0.00369682, + "score": 0.004133 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00011018, - "spread": 0.00351586, - "score": 0.00351759 + "bias": 0.00152323, + "spread": 0.00443579, + "score": 0.00469004 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00170508, - "spread": 0.00312859, - "score": 0.00356306 + "bias": -0.00164992, + "spread": 0.00334342, + "score": 0.00372836 }, { "profile": "cp_plus_3pct", @@ -14029,9 +14029,9 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": 0.00168437, - "spread": 0.05339005, - "score": 0.05341661 + "bias": 0.00333057, + "spread": 0.04984519, + "score": 0.04995634 }, { "profile": "cp_plus_3pct", @@ -14065,90 +14065,90 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00124987, - "spread": 0.01181842, - "score": 0.01188433 + "bias": 0.00294616, + "spread": 0.01204488, + "score": 0.01239996 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00056103, - "spread": 0.00376058, - "score": 0.0038022 + "bias": -0.0023287, + "spread": 0.00544172, + "score": 0.00591905 }, { "profile": "cp_plus_3pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -4.991e-05, - "spread": 0.00230403, - "score": 0.00230457 + "bias": 0.00110224, + "spread": 0.0024726, + "score": 0.00270715 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "overall", "condition_bin": "overall", - "bias": 0.00180323, - "spread": 0.00184107, - "score": 0.00257705 + "bias": 0.00093922, + "spread": 0.00205907, + "score": 0.00226317 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00836838, - "spread": 0.01515533, - "score": 0.01731224 + "bias": 0.0096141, + "spread": 0.01048466, + "score": 0.01422529 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00336162, - "spread": 0.00332948, - "score": 0.00473138 + "bias": -0.00058392, + "spread": 0.00221741, + "score": 0.002293 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00057749, - "spread": 0.00050018, - "score": 0.00076398 + "bias": -0.00068463, + "spread": 0.00150937, + "score": 0.00165739 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00069242, - "spread": 0.00313425, - "score": 0.00320982 + "bias": 0.00048093, + "spread": 0.00360337, + "score": 0.00363533 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00492627, - "spread": 0.00676362, - "score": 0.00836748 + "bias": 0.00208526, + "spread": 0.00591779, + "score": 0.00627444 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00314783, - "spread": 0.00371753, - "score": 0.00487123 + "bias": 8.727e-05, + "spread": 0.00323183, + "score": 0.00323301 }, { "profile": "cp_plus_3pct", @@ -14164,126 +14164,126 @@ "campaign_months": 3, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00177451, - "spread": 0.01019567, - "score": 0.01034894 + "bias": 0.00117857, + "spread": 0.00730044, + "score": 0.00739496 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 5.367e-05, - "spread": 0.00258889, - "score": 0.00258944 + "bias": 0.00031765, + "spread": 0.00212956, + "score": 0.00215312 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00372479, - "spread": 0.0027198, - "score": 0.00461209 + "bias": 0.00257153, + "spread": 0.00252502, + "score": 0.00360395 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00084001, - "spread": 0.00497078, - "score": 0.00504125 + "bias": -0.00047398, + "spread": 0.00377089, + "score": 0.00380056 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00112254, - "spread": 0.00622563, - "score": 0.00632602 + "bias": -0.00397553, + "spread": 0.0076216, + "score": 0.00859614 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00767417, - "spread": 0.03297709, - "score": 0.03385825 + "bias": -0.02138604, + "spread": 0.03129798, + "score": 0.03790681 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.0029632, - "spread": 0.04035468, - "score": 0.04046333 + "bias": 0.00091065, + "spread": 0.05340601, + "score": 0.05341377 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": -0.25588611, - "spread": 0.45185537, - "score": 0.51927929 + "bias": -0.26348329, + "spread": 0.45971807, + "score": 0.52987183 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.10371183, - "spread": 0.14868284, - "score": 0.1812808 + "bias": 0.10284782, + "spread": 0.14764557, + "score": 0.17993579 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.00178213, - "spread": 0.02489506, - "score": 0.02495876 + "bias": -0.03403961, + "spread": 0.02295193, + "score": 0.04105467 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00157696, - "spread": 0.00082138, - "score": 0.00177805 + "bias": -0.00137343, + "spread": 0.0020214, + "score": 0.00244385 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00257405, - "spread": 0.00295079, - "score": 0.00391572 + "bias": 0.00267504, + "spread": 0.00333985, + "score": 0.00427907 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00128241, - "spread": 0.00378197, - "score": 0.00399348 + "bias": 0.00034585, + "spread": 0.00398343, + "score": 0.00399842 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": -0.00042041, - "spread": 0.00047419, - "score": 0.00063372 + "bias": -0.00020522, + "spread": 0.00017029, + "score": 0.00026667 }, { "profile": "cp_plus_3pct", @@ -14299,9 +14299,9 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": 0.00571813, - "spread": 0.03661746, - "score": 0.03706123 + "bias": 0.0078715, + "spread": 0.02534366, + "score": 0.02653793 }, { "profile": "cp_plus_3pct", @@ -14335,243 +14335,243 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00093851, - "spread": 0.01299199, - "score": 0.01302585 + "bias": -0.00107243, + "spread": 0.00969328, + "score": 0.00975243 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00610035, - "spread": 0.00522719, - "score": 0.00803354 + "bias": 0.00222534, + "spread": 0.00465572, + "score": 0.00516022 }, { "profile": "cp_plus_3pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00112161, - "spread": 0.0043043, - "score": 0.00444803 + "bias": 0.00081516, + "spread": 0.00360198, + "score": 0.00369307 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "overall", "condition_bin": "overall", - "bias": 0.00149298, - "spread": 0.00153721, - "score": 0.00214289 + "bias": 0.00139852, + "spread": 0.00140877, + "score": 0.00198506 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00030001, - "spread": 0.00260377, - "score": 0.002621 + "bias": 0.00210277, + "spread": 0.00409438, + "score": 0.00460278 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00099712, - "spread": 0.00448475, - "score": 0.00459426 + "bias": 0.00144734, + "spread": 0.00387553, + "score": 0.00413697 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00088231, - "spread": 0.00319765, - "score": 0.00331714 + "bias": -0.00069191, + "spread": 0.00280366, + "score": 0.00288778 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00111293, - "spread": 0.0025814, - "score": 0.0028111 + "bias": 0.00088405, + "spread": 0.00309414, + "score": 0.00321796 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00208812, - "spread": 0.00261735, - "score": 0.00334825 + "bias": 0.00163496, + "spread": 0.00231658, + "score": 0.00283542 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00207184, - "spread": 0.00659866, - "score": 0.00691627 + "bias": 0.00163767, + "spread": 0.00546533, + "score": 0.00570542 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.00916885, - "spread": 0.00061443, - "score": 0.00918941 + "bias": -0.00914443, + "spread": 0.00105711, + "score": 0.00920533 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -2.6e-07, - "spread": 0.01048645, - "score": 0.01048645 + "bias": 0.00099428, + "spread": 0.00754204, + "score": 0.00760729 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00064749, - "spread": 0.00164407, - "score": 0.00176698 + "bias": 0.00128373, + "spread": 0.0014098, + "score": 0.0019067 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00286744, - "spread": 0.00155175, - "score": 0.00326039 + "bias": 0.00266062, + "spread": 0.00095871, + "score": 0.00282807 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00085053, - "spread": 0.00229437, - "score": 0.00244694 + "bias": -0.00094377, + "spread": 0.00156549, + "score": 0.00182796 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00087381, - "spread": 0.00979282, - "score": 0.00983173 + "bias": -0.00187993, + "spread": 0.01107259, + "score": 0.01123104 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.00229511, - "spread": 0.02187709, - "score": 0.02199715 + "bias": -0.00507017, + "spread": 0.01594072, + "score": 0.01672762 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.04305858, - "spread": 0.02984954, - "score": 0.05239309 + "bias": -0.05191155, + "spread": 0.03408157, + "score": 0.06209962 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.07214575, - "spread": 0.20350397, - "score": 0.21591405 + "bias": 0.06463133, + "spread": 0.22048928, + "score": 0.22976669 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.03003562, - "spread": 0.03436552, - "score": 0.04564129 + "bias": 0.02994116, + "spread": 0.03408984, + "score": 0.04537169 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.00393136, - "spread": 0.01556319, - "score": 0.01605205 + "bias": -0.01357771, + "spread": 0.0307651, + "score": 0.03362805 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00018979, - "spread": 0.00109564, - "score": 0.00111195 + "bias": 0.00032263, + "spread": 0.00063814, + "score": 0.00071506 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00218473, - "spread": 0.00214066, - "score": 0.00305867 + "bias": 0.00163519, + "spread": 0.00229853, + "score": 0.00282083 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00059953, - "spread": 0.00266338, - "score": 0.00273003 + "bias": 0.00012387, + "spread": 0.00314439, + "score": 0.00314683 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00070364, - "spread": 0.00229128, - "score": 0.00239688 + "bias": 0.00023662, + "spread": 0.00226988, + "score": 0.00228218 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": -0.0004253, - "spread": 0.00063351, - "score": 0.00076303 + "bias": -0.00055357, + "spread": 0.00061871, + "score": 0.0008302 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.01898044, - "spread": 0.03251592, - "score": 0.03765026 + "bias": -0.00985108, + "spread": 0.02547274, + "score": 0.02731125 }, { "profile": "cp_plus_3pct", @@ -14605,252 +14605,252 @@ "campaign_months": 6, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00241685, - "spread": 0.0054525, - "score": 0.00596414 + "bias": 0.0029506, + "spread": 0.00415077, + "score": 0.00509264 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00072031, - "spread": 0.00281083, - "score": 0.00290165 + "bias": 0.0003658, + "spread": 0.00230326, + "score": 0.00233213 }, { "profile": "cp_plus_3pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00233771, - "spread": 0.00553958, - "score": 0.00601265 + "bias": 0.00264803, + "spread": 0.00536693, + "score": 0.00598464 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "overall", "condition_bin": "overall", - "bias": 0.00065249, - "spread": 0.00182048, - "score": 0.00193387 + "bias": 0.00040997, + "spread": 0.00168552, + "score": 0.00173466 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.0007528, - "spread": 0.00609566, - "score": 0.00614197 + "bias": 0.00074871, + "spread": 0.00550389, + "score": 0.00555458 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00340901, - "spread": 0.00381316, - "score": 0.00511483 + "bias": -0.00211787, + "spread": 0.00278004, + "score": 0.00349485 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": -4.433e-05, - "spread": 0.00155955, - "score": 0.00156018 + "bias": 0.00040736, + "spread": 0.00166985, + "score": 0.00171882 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00058363, - "spread": 0.00150839, - "score": 0.00161737 + "bias": 0.00033186, + "spread": 0.00158661, + "score": 0.00162095 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00289113, - "spread": 0.0030776, - "score": 0.00422259 + "bias": 0.00207617, + "spread": 0.00233344, + "score": 0.00312336 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00079355, - "spread": 0.00318295, - "score": 0.00328038 + "bias": -0.00059909, + "spread": 0.00364045, + "score": 0.00368941 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.0024238, - "spread": 0.01409659, - "score": 0.01430345 + "bias": -0.00261342, + "spread": 0.01420682, + "score": 0.0144452 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00157778, - "spread": 0.0043806, - "score": 0.00465607 + "bias": 0.00162969, + "spread": 0.0020899, + "score": 0.0026502 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00116618, - "spread": 0.00159557, - "score": 0.00197632 + "bias": 0.00101152, + "spread": 0.00146443, + "score": 0.00177981 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00023041, - "spread": 0.00143503, - "score": 0.00145341 + "bias": 4.641e-05, + "spread": 0.00127296, + "score": 0.00127381 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.0001521, - "spread": 0.00314094, - "score": 0.00314463 + "bias": -0.00041482, + "spread": 0.00320973, + "score": 0.00323642 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.0041865, - "spread": 0.00741872, - "score": 0.00851846 + "bias": 0.00208777, + "spread": 0.00504535, + "score": 0.00546025 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00597612, - "spread": 0.0309225, - "score": 0.03149468 + "bias": -0.00262205, + "spread": 0.0304334, + "score": 0.03054615 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.014087, - "spread": 0.01971759, - "score": 0.02423277 + "bias": -0.02298696, + "spread": 0.03566625, + "score": 0.04243208 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.02608971, - "spread": 0.24291303, - "score": 0.24431008 + "bias": 0.01645778, + "spread": 0.23658042, + "score": 0.23715218 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.1088106, - "spread": 0.14973697, - "score": 0.18509702 + "bias": 0.14820648, + "spread": 0.1646682, + "score": 0.22154182 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.01040754, - "spread": 0.0481373, - "score": 0.04924953 + "bias": -0.01109154, + "spread": 0.0338536, + "score": 0.03562427 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00137916, - "spread": 0.00255668, - "score": 0.00290495 + "bias": -0.00110901, + "spread": 0.00159052, + "score": 0.00193898 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00237116, - "spread": 0.00088799, - "score": 0.00253198 + "bias": 0.00208459, + "spread": 0.0010919, + "score": 0.00235325 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00036279, - "spread": 0.00157332, - "score": 0.00161461 + "bias": -0.00050997, + "spread": 0.00162433, + "score": 0.0017025 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00048286, - "spread": 0.00115074, - "score": 0.00124794 + "bias": 0.00018335, + "spread": 0.00134862, + "score": 0.00136103 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": -0.00102724, - "spread": 0.00198108, - "score": 0.00223157 + "bias": -0.00103029, + "spread": 0.00226883, + "score": 0.0024918 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.0042678, - "spread": 0.01906216, - "score": 0.01953407 + "bias": -0.00599165, + "spread": 0.01147293, + "score": 0.01294327 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": -0.00225854, - "spread": 0.00261186, - "score": 0.00345294 + "bias": -0.00285476, + "spread": 0.00304607, + "score": 0.00417471 }, { "profile": "cp_plus_3pct", @@ -14875,90 +14875,90 @@ "campaign_months": 12, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00119143, - "spread": 0.00627168, - "score": 0.00638384 + "bias": 0.00104787, + "spread": 0.00612062, + "score": 0.00620967 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00306532, - "spread": 0.00329207, - "score": 0.00449821 + "bias": 0.00272801, + "spread": 0.00263766, + "score": 0.00379464 }, { "profile": "cp_plus_3pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -5.835e-05, - "spread": 0.00303271, - "score": 0.00303327 + "bias": -0.00079123, + "spread": 0.00295694, + "score": 0.00306097 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "overall", "condition_bin": "overall", - "bias": -0.0030756, - "spread": 0.00229937, - "score": 0.0038401 + "bias": -0.00351025, + "spread": 0.00334085, + "score": 0.00484594 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.01478914, - "spread": 0.02672256, - "score": 0.030542 + "bias": 0.01427515, + "spread": 0.02701687, + "score": 0.03055636 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00185144, - "spread": 0.01054317, - "score": 0.0107045 + "bias": 0.00019993, + "spread": 0.00672723, + "score": 0.0067302 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00593935, - "spread": 0.01207745, - "score": 0.01345886 + "bias": 0.00464193, + "spread": 0.01457773, + "score": 0.01529895 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.01320231, - "spread": 0.02049543, - "score": 0.02437958 + "bias": -0.01313321, + "spread": 0.02041401, + "score": 0.02427371 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00313941, - "spread": 0.0075615, - "score": 0.00818732 + "bias": -0.00708362, + "spread": 0.00902362, + "score": 0.01147185 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00806057, - "spread": 0.00967871, - "score": 0.01259564 + "bias": -0.00595473, + "spread": 0.00798275, + "score": 0.00995908 }, { "profile": "rated_plus_5pct", @@ -14974,117 +14974,117 @@ "campaign_months": 1, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00420255, - "spread": 0.00629493, - "score": 0.00756886 + "bias": 0.00072056, + "spread": 0.00622605, + "score": 0.00626761 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00443799, - "spread": 0.00244783, - "score": 0.0050683 + "bias": -0.00471573, + "spread": 0.0013288, + "score": 0.00489937 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00229214, - "spread": 0.00455254, - "score": 0.00509701 + "bias": -0.00217597, + "spread": 0.00538925, + "score": 0.00581196 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00145451, - "spread": 0.00539022, - "score": 0.00558301 + "bias": -0.00281547, + "spread": 0.00477817, + "score": 0.00554597 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00254552, - "spread": 0.00380516, - "score": 0.00457809 + "bias": -0.01304145, + "spread": 0.00637521, + "score": 0.01451629 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.01862515, - "spread": 0.05467725, - "score": 0.05776243 + "bias": -0.0146939, + "spread": 0.04836676, + "score": 0.05054952 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.01068045, - "spread": 0.00561563, - "score": 0.01206679 + "bias": 0.0102458, + "spread": 0.00656771, + "score": 0.0121701 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.01068016, - "spread": 0.00559998, - "score": 0.01205926 + "bias": 0.01024551, + "spread": 0.00655423, + "score": 0.01216258 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.01068046, - "spread": 0.00562224, - "score": 0.01206987 + "bias": 0.01024581, + "spread": 0.0065763, + "score": 0.01217474 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.00194484, - "spread": 0.06170374, - "score": 0.06173438 + "bias": 0.01599401, + "spread": 0.04543088, + "score": 0.04816402 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00311572, - "spread": 0.01091478, - "score": 0.01135078 + "bias": 0.00224853, + "spread": 0.00971319, + "score": 0.00997006 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": -0.00643615, - "spread": 0.02209322, - "score": 0.02301161 + "bias": -0.00660049, + "spread": 0.02249256, + "score": 0.02344102 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.03691403, - "spread": 0.02152682, - "score": 0.0427323 + "bias": -0.03682174, + "spread": 0.02168667, + "score": 0.0427335 }, { "profile": "rated_plus_5pct", @@ -15109,9 +15109,9 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": 0.00313805, - "spread": 0.06137769, - "score": 0.06145785 + "bias": -0.00242055, + "spread": 0.0839863, + "score": 0.08402118 }, { "profile": "rated_plus_5pct", @@ -15145,90 +15145,90 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00283884, - "spread": 0.01843828, - "score": 0.01865554 + "bias": 0.00574786, + "spread": 0.01669417, + "score": 0.01765597 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00101614, - "spread": 0.00393623, - "score": 0.00406527 + "bias": -0.00320473, + "spread": 0.00529057, + "score": 0.0061855 }, { "profile": "rated_plus_5pct", "campaign_months": 1, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00331063, - "spread": 0.00643391, - "score": 0.00723571 + "bias": 0.003057, + "spread": 0.0047653, + "score": 0.00566156 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "overall", "condition_bin": "overall", - "bias": -0.00121559, - "spread": 0.00162645, - "score": 0.00203051 + "bias": -0.00047202, + "spread": 0.00231668, + "score": 0.00236428 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.01071882, - "spread": 0.02044472, - "score": 0.02308419 + "bias": 0.01312976, + "spread": 0.01676061, + "score": 0.02129105 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.0005071, - "spread": 0.00685228, - "score": 0.00687102 + "bias": 0.00027464, + "spread": 0.00560845, + "score": 0.00561517 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.0070095, - "spread": 0.00458668, - "score": 0.0083768 + "bias": 0.00617839, + "spread": 0.00628355, + "score": 0.00881223 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00540974, - "spread": 0.00344571, - "score": 0.00641391 + "bias": -0.00448465, + "spread": 0.00242914, + "score": 0.00510027 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00299792, - "spread": 0.00354939, - "score": 0.00464604 + "bias": 0.00035946, + "spread": 0.0042642, + "score": 0.00427933 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00544646, - "spread": 0.00656992, - "score": 0.00853392 + "bias": -0.00204644, + "spread": 0.00568112, + "score": 0.00603847 }, { "profile": "rated_plus_5pct", @@ -15244,117 +15244,117 @@ "campaign_months": 2, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00526792, - "spread": 0.00426944, - "score": 0.00678079 + "bias": 0.0071423, + "spread": 0.00289313, + "score": 0.00770602 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00493712, - "spread": 0.00336329, - "score": 0.00597385 + "bias": -0.00356253, + "spread": 0.00165781, + "score": 0.00392937 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.0023152, - "spread": 0.00414419, - "score": 0.00474705 + "bias": 0.00250931, + "spread": 0.00487173, + "score": 0.00548 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00140177, - "spread": 0.00549256, - "score": 0.00566861 + "bias": 0.00037849, + "spread": 0.00486759, + "score": 0.00488228 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.0042255, - "spread": 0.00745338, - "score": 0.00856783 + "bias": -0.00864745, + "spread": 0.01003132, + "score": 0.01324409 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.02162857, - "spread": 0.07565648, - "score": 0.07868734 + "bias": -0.02308616, + "spread": 0.06687686, + "score": 0.07074946 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.10467555, - "spread": 0.18869229, - "score": 0.21578172 + "bias": 0.1078026, + "spread": 0.18072939, + "score": 0.21043886 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.00601488, - "spread": 0.01196043, - "score": 0.01338771 + "bias": -0.00415726, + "spread": 0.03134667, + "score": 0.03162114 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.01402895, - "spread": 0.00364216, - "score": 0.01449403 + "bias": 2.64982954, + "spread": 4.56566537, + "score": 5.27891058 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.03103419, - "spread": 0.02656573, - "score": 0.04085167 + "bias": 0.00449742, + "spread": 0.05862824, + "score": 0.05880048 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00029731, - "spread": 0.00196638, - "score": 0.00198873 + "bias": 0.00220781, + "spread": 0.00368357, + "score": 0.00429454 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": -0.00097953, - "spread": 0.00380047, - "score": 0.00392467 + "bias": 0.00023007, + "spread": 0.003764, + "score": 0.00377102 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00092485, - "spread": 0.00234195, - "score": 0.00251795 + "bias": -0.00084806, + "spread": 0.00287901, + "score": 0.00300131 }, { "profile": "rated_plus_5pct", @@ -15379,9 +15379,9 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": 0.01088979, - "spread": 0.05576764, - "score": 0.05682092 + "bias": 0.00644252, + "spread": 0.04679499, + "score": 0.0472364 }, { "profile": "rated_plus_5pct", @@ -15415,90 +15415,90 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00249177, - "spread": 0.01326539, - "score": 0.01349739 + "bias": 0.00459345, + "spread": 0.01205201, + "score": 0.0128977 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00160145, - "spread": 0.00462055, - "score": 0.0048902 + "bias": 0.00037032, + "spread": 0.00550766, + "score": 0.00552009 }, { "profile": "rated_plus_5pct", "campaign_months": 2, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00299715, - "spread": 0.00237329, - "score": 0.00382301 + "bias": 0.00388583, + "spread": 0.00162175, + "score": 0.00421067 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "overall", "condition_bin": "overall", - "bias": 0.00182837, - "spread": 0.00184695, - "score": 0.00259887 + "bias": 0.00100976, + "spread": 0.00217599, + "score": 0.00239886 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00918062, - "spread": 0.01412632, - "score": 0.01684746 + "bias": 0.01074205, + "spread": 0.01038445, + "score": 0.01494083 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00186504, - "spread": 0.00331884, - "score": 0.00380697 + "bias": 0.00169695, + "spread": 0.00358489, + "score": 0.00396624 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00520441, - "spread": 0.00135889, - "score": 0.0053789 + "bias": 0.00364192, + "spread": 0.00314438, + "score": 0.00481152 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00179325, - "spread": 0.00312091, - "score": 0.00359941 + "bias": -0.00167221, + "spread": 0.00285194, + "score": 0.00330603 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00562239, - "spread": 0.0055063, - "score": 0.0078696 + "bias": 0.00180055, + "spread": 0.00460189, + "score": 0.0049416 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00194181, - "spread": 0.00390706, - "score": 0.004363 + "bias": 0.00074869, + "spread": 0.00405625, + "score": 0.00412476 }, { "profile": "rated_plus_5pct", @@ -15514,126 +15514,126 @@ "campaign_months": 3, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -5.812e-05, - "spread": 0.01093513, - "score": 0.01093529 + "bias": -0.00037737, + "spread": 0.0069729, + "score": 0.0069831 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00094836, - "spread": 0.00241172, - "score": 0.00259148 + "bias": -0.0001854, + "spread": 0.00223119, + "score": 0.00223888 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00367147, - "spread": 0.00289191, - "score": 0.00467363 + "bias": 0.00250025, + "spread": 0.00344878, + "score": 0.00425974 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00276045, - "spread": 0.0051827, - "score": 0.00587201 + "bias": 0.00123011, + "spread": 0.00356381, + "score": 0.00377014 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00517839, - "spread": 0.00679705, - "score": 0.00854491 + "bias": -0.00232398, + "spread": 0.00772481, + "score": 0.00806682 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00513483, - "spread": 0.03580837, - "score": 0.03617466 + "bias": -0.02159476, + "spread": 0.03463427, + "score": 0.04081503 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.01006111, - "spread": 0.03402578, - "score": 0.0354821 + "bias": 0.00292093, + "spread": 0.06043672, + "score": 0.06050726 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": -0.14487552, - "spread": 0.27736542, - "score": 0.3129225 + "bias": -0.15601717, + "spread": 0.28352104, + "score": 0.32361326 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.01786503, - "spread": 0.00127409, - "score": 0.0179104 + "bias": 0.01704642, + "spread": 0.00156907, + "score": 0.01711848 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.00805019, - "spread": 0.02233219, - "score": 0.02373884 + "bias": -0.02581217, + "spread": 0.02591257, + "score": 0.03657499 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00256097, - "spread": 0.00096549, - "score": 0.00273692 + "bias": 0.00281516, + "spread": 0.00167965, + "score": 0.00327816 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00100167, - "spread": 0.00248266, - "score": 0.00267711 + "bias": 0.00126996, + "spread": 0.00230912, + "score": 0.00263531 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00185566, - "spread": 0.00328251, - "score": 0.00377073 + "bias": 0.00097111, + "spread": 0.00338622, + "score": 0.00352272 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": -0.03724103, - "spread": 0.02084082, - "score": 0.04267592 + "bias": -0.03682189, + "spread": 0.02156673, + "score": 0.04267289 }, { "profile": "rated_plus_5pct", @@ -15649,9 +15649,9 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": 0.01450379, - "spread": 0.03797868, - "score": 0.04065391 + "bias": 0.0164208, + "spread": 0.02910406, + "score": 0.03341689 }, { "profile": "rated_plus_5pct", @@ -15685,243 +15685,243 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00230543, - "spread": 0.01383628, - "score": 0.01402703 + "bias": 0.00120955, + "spread": 0.01019418, + "score": 0.01026568 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00741029, - "spread": 0.00574957, - "score": 0.00937923 + "bias": 0.00332214, + "spread": 0.00533534, + "score": 0.0062851 }, { "profile": "rated_plus_5pct", "campaign_months": 3, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00371152, - "spread": 0.00458761, - "score": 0.00590098 + "bias": 0.00351307, + "spread": 0.00429356, + "score": 0.00554764 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "overall", "condition_bin": "overall", - "bias": 0.00153543, - "spread": 0.0015354, - "score": 0.0021714 + "bias": 0.00134749, + "spread": 0.00150907, + "score": 0.00202312 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00151446, - "spread": 0.002269, - "score": 0.00272799 + "bias": 0.00446292, + "spread": 0.00307699, + "score": 0.00542084 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00334162, - "spread": 0.00313303, - "score": 0.00458064 + "bias": 0.00333748, + "spread": 0.00391358, + "score": 0.00514343 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00449783, - "spread": 0.00351429, - "score": 0.00570795 + "bias": 0.00253746, + "spread": 0.00261608, + "score": 0.00364452 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00060612, - "spread": 0.00260506, - "score": 0.00267465 + "bias": -0.00061258, + "spread": 0.00334294, + "score": 0.0033986 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00310049, - "spread": 0.00262227, - "score": 0.00406071 + "bias": 0.00157616, + "spread": 0.00174221, + "score": 0.00234937 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00180246, - "spread": 0.0057323, - "score": 0.00600901 + "bias": 0.00275236, + "spread": 0.00572582, + "score": 0.00635299 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.01746784, - "spread": 0.0020415, - "score": 0.01758674 + "bias": 0.0174817, + "spread": 0.00161289, + "score": 0.01755594 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00134274, - "spread": 0.01163363, - "score": 0.01171086 + "bias": 0.00087882, + "spread": 0.00836648, + "score": 0.00841251 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -6.416e-05, - "spread": 0.00150607, - "score": 0.00150743 + "bias": 0.00048188, + "spread": 0.00158111, + "score": 0.00165291 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.0030296, - "spread": 0.00119119, - "score": 0.00325537 + "bias": 0.00267595, + "spread": 0.00112972, + "score": 0.00290465 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00015251, - "spread": 0.00228695, - "score": 0.00229203 + "bias": 0.00023929, + "spread": 0.00118924, + "score": 0.00121308 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00333565, - "spread": 0.01044247, - "score": 0.01096228 + "bias": 0.00065255, + "spread": 0.01182567, + "score": 0.01184366 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.00287107, - "spread": 0.0193133, - "score": 0.01952554 + "bias": -0.00332308, + "spread": 0.01424124, + "score": 0.01462381 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.03605172, - "spread": 0.03018668, - "score": 0.04702087 + "bias": -0.04703917, + "spread": 0.02762681, + "score": 0.05455203 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.06128761, - "spread": 0.16897928, - "score": 0.17975029 + "bias": 0.03827974, + "spread": 0.18766359, + "score": 0.19152797 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.01848281, - "spread": 0.0031539, - "score": 0.01874997 + "bias": 0.01829487, + "spread": 0.00330833, + "score": 0.01859159 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.00508538, - "spread": 0.01581023, - "score": 0.01660796 + "bias": -0.02277589, + "spread": 0.04214703, + "score": 0.04790734 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00317197, - "spread": 0.00161025, - "score": 0.00355729 + "bias": 0.00301664, + "spread": 0.001061, + "score": 0.00319779 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": -0.0009096, - "spread": 0.00179299, - "score": 0.00201052 + "bias": -0.00105066, + "spread": 0.00202962, + "score": 0.00228544 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00018171, - "spread": 0.00242747, - "score": 0.00243426 + "bias": -0.00015157, + "spread": 0.00303403, + "score": 0.00303781 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00108271, - "spread": 0.00210152, - "score": 0.00236404 + "bias": 0.00060882, + "spread": 0.00212435, + "score": 0.00220987 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": -0.0361096, - "spread": 0.02192102, - "score": 0.04224257 + "bias": -0.03628426, + "spread": 0.02161881, + "score": 0.04223648 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.00430704, - "spread": 0.0267748, - "score": 0.027119 + "bias": 0.00023114, + "spread": 0.02453587, + "score": 0.02453696 }, { "profile": "rated_plus_5pct", @@ -15955,252 +15955,252 @@ "campaign_months": 6, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00352032, - "spread": 0.00507414, - "score": 0.00617572 + "bias": 0.00366593, + "spread": 0.00429362, + "score": 0.00564573 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00092768, - "spread": 0.00324281, - "score": 0.00337289 + "bias": 0.00069726, + "spread": 0.00227237, + "score": 0.00237693 }, { "profile": "rated_plus_5pct", "campaign_months": 6, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00379758, - "spread": 0.00536257, - "score": 0.00657106 + "bias": 0.00372953, + "spread": 0.00540566, + "score": 0.00656739 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "overall", "condition_bin": "overall", - "bias": 0.00069611, - "spread": 0.00182653, - "score": 0.00195468 + "bias": 0.00044484, + "spread": 0.00167749, + "score": 0.00173547 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00216346, - "spread": 0.00561145, - "score": 0.00601406 + "bias": 0.00138233, + "spread": 0.00443918, + "score": 0.00464943 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": 0.00023649, - "spread": 0.00418962, - "score": 0.00419628 + "bias": 0.00082459, + "spread": 0.00275733, + "score": 0.00287799 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.0037074, - "spread": 0.00196085, - "score": 0.00419401 + "bias": 0.0027369, + "spread": 0.00207844, + "score": 0.00343664 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00042569, - "spread": 0.00159993, - "score": 0.00165559 + "bias": -0.00057542, + "spread": 0.00173484, + "score": 0.00182778 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00374255, - "spread": 0.00325439, - "score": 0.00495961 + "bias": 0.0030302, + "spread": 0.00257109, + "score": 0.00397399 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00127113, - "spread": 0.00280386, - "score": 0.00307854 + "bias": -4.782e-05, + "spread": 0.00327601, + "score": 0.00327636 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": 0.02158696, - "spread": 0.00062017, - "score": 0.02159587 + "bias": 0.02138621, + "spread": 0.00045929, + "score": 0.02139114 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00136964, - "spread": 0.00482067, - "score": 0.00501146 + "bias": 0.00205713, + "spread": 0.00320476, + "score": 0.00380819 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00038169, - "spread": 0.00160542, - "score": 0.00165017 + "bias": 0.00045248, + "spread": 0.00130858, + "score": 0.0013846 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00023744, - "spread": 0.00137839, - "score": 0.00139869 + "bias": 9.185e-05, + "spread": 0.00134778, + "score": 0.00135091 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00160729, - "spread": 0.00290891, - "score": 0.00332342 + "bias": 0.0006233, + "spread": 0.00290784, + "score": 0.0029739 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00754028, - "spread": 0.00765317, - "score": 0.01074369 + "bias": 0.00473882, + "spread": 0.00592313, + "score": 0.0075855 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00308575, - "spread": 0.02933894, - "score": 0.02950076 + "bias": -0.00192059, + "spread": 0.02961039, + "score": 0.02967261 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.01021607, - "spread": 0.0208989, - "score": 0.02326224 + "bias": -0.01767475, + "spread": 0.03333377, + "score": 0.03772979 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.03343226, - "spread": 0.23164288, - "score": 0.23404303 + "bias": 0.02953422, + "spread": 0.22628383, + "score": 0.22820307 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.07642297, - "spread": 0.11039854, - "score": 0.13426954 + "bias": -0.04017426, + "spread": 0.08032751, + "score": 0.08981359 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.01236254, - "spread": 0.04065073, - "score": 0.04248899 + "bias": -0.01913695, + "spread": 0.04515661, + "score": 0.04904428 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00149205, - "spread": 0.00247647, - "score": 0.00289121 + "bias": 0.0015605, + "spread": 0.00129595, + "score": 0.00202846 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": -0.00060498, - "spread": 0.00096989, - "score": 0.0011431 + "bias": -0.00047699, + "spread": 0.00104484, + "score": 0.00114857 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00116514, - "spread": 0.00167345, - "score": 0.00203911 + "bias": -0.00108799, + "spread": 0.00192305, + "score": 0.00220949 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00034439, - "spread": 0.00144639, - "score": 0.00148682 + "bias": 0.00013538, + "spread": 0.00153331, + "score": 0.00153928 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 0.00031721, - "spread": 0.00083464, - "score": 0.00089289 + "bias": 0.00029208, + "spread": 0.00126601, + "score": 0.00129927 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": 0.00370617, - "spread": 0.02080587, - "score": 0.02113339 + "bias": 0.00778356, + "spread": 0.01683451, + "score": 0.01854682 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": -0.02356071, - "spread": 0.02256018, - "score": 0.03262006 + "bias": -0.02431824, + "spread": 0.0218464, + "score": 0.03269009 }, { "profile": "rated_plus_5pct", @@ -16225,90 +16225,90 @@ "campaign_months": 12, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00176493, - "spread": 0.00585286, - "score": 0.00611318 + "bias": 0.00065387, + "spread": 0.00524345, + "score": 0.00528407 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00299885, - "spread": 0.00339284, - "score": 0.00452819 + "bias": 0.00254303, + "spread": 0.00278271, + "score": 0.00376968 }, { "profile": "rated_plus_5pct", "campaign_months": 12, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00102738, - "spread": 0.00332194, - "score": 0.00347718 + "bias": 0.00019374, + "spread": 0.00320789, + "score": 0.00321373 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "overall", "condition_bin": "overall", - "bias": -0.00319538, - "spread": 0.00238353, - "score": 0.00398643 + "bias": -0.00386986, + "spread": 0.00347878, + "score": 0.00520362 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.01355147, - "spread": 0.02569807, - "score": 0.02905225 + "bias": 0.01040414, + "spread": 0.03075189, + "score": 0.03246421 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00413457, - "spread": 0.00740385, - "score": 0.00848008 + "bias": -0.00326789, + "spread": 0.00617529, + "score": 0.00698665 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": -0.00077662, - "spread": 0.0103144, - "score": 0.01034359 + "bias": -0.00234275, + "spread": 0.00999612, + "score": 0.01026698 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.0005711, - "spread": 0.00454388, - "score": 0.00457963 + "bias": 6.955e-05, + "spread": 0.00449864, + "score": 0.00449918 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00384801, - "spread": 0.00936032, - "score": 0.01012041 + "bias": -0.00806262, + "spread": 0.01352868, + "score": 0.015749 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00922264, - "spread": 0.00897444, - "score": 0.01286848 + "bias": -0.00743992, + "spread": 0.00682961, + "score": 0.01009931 }, { "profile": "ti_dependent_cp", @@ -16324,117 +16324,117 @@ "campaign_months": 1, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.02475551, - "spread": 0.02287859, - "score": 0.03370853 + "bias": -0.01885952, + "spread": 0.0272379, + "score": 0.03312981 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00597225, - "spread": 0.00277245, - "score": 0.00658439 + "bias": -0.00646551, + "spread": 0.00309329, + "score": 0.00716737 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00284303, - "spread": 0.00375503, - "score": 0.00470989 + "bias": -0.0032369, + "spread": 0.00478498, + "score": 0.00577698 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00068278, - "spread": 0.00586672, - "score": 0.00590632 + "bias": -0.00183554, + "spread": 0.00612837, + "score": 0.00639735 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00764922, - "spread": 0.00479046, - "score": 0.00902546 + "bias": -0.00176153, + "spread": 0.00568608, + "score": 0.00595269 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.00672577, - "spread": 0.05639263, - "score": 0.0567923 + "bias": 0.00901391, + "spread": 0.05285202, + "score": 0.05361517 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.04070709, - "spread": 0.00786051, - "score": 0.04145907 + "bias": 0.04003261, + "spread": 0.0079388, + "score": 0.04081218 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.04070709, - "spread": 0.00786051, - "score": 0.04145907 + "bias": 0.04003261, + "spread": 0.0079388, + "score": 0.04081218 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.04070709, - "spread": 0.00786051, - "score": 0.04145907 + "bias": 0.04003261, + "spread": 0.0079388, + "score": 0.04081218 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.01689397, - "spread": 0.06031245, - "score": 0.06263384 + "bias": -0.00606348, + "spread": 0.04818626, + "score": 0.04856626 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00521103, - "spread": 0.00900099, - "score": 0.01040061 + "bias": -0.00545629, + "spread": 0.00883372, + "score": 0.01038296 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00093986, - "spread": 0.01163952, - "score": 0.0116774 + "bias": 0.00035197, + "spread": 0.01162316, + "score": 0.01162848 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00144112, - "spread": 0.00145728, - "score": 0.00204951 + "bias": -0.00147966, + "spread": 0.00152338, + "score": 0.00212369 }, { "profile": "ti_dependent_cp", @@ -16459,9 +16459,9 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.01204854, - "spread": 0.05961413, - "score": 0.0608195 + "bias": -0.0206645, + "spread": 0.0971562, + "score": 0.0993295 }, { "profile": "ti_dependent_cp", @@ -16495,90 +16495,90 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00188407, - "spread": 0.01697078, - "score": 0.01707504 + "bias": 0.00101158, + "spread": 0.01672721, + "score": 0.01675777 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00472689, - "spread": 0.004717, - "score": 0.00667784 + "bias": -0.00739284, + "spread": 0.00666285, + "score": 0.00995226 }, { "profile": "ti_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.0024182, - "spread": 0.00601484, - "score": 0.00648275 + "bias": -0.00315338, + "spread": 0.00405021, + "score": 0.00513303 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "overall", "condition_bin": "overall", - "bias": -0.00129, - "spread": 0.00167909, - "score": 0.00211741 + "bias": -0.00070447, + "spread": 0.00244588, + "score": 0.00254531 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.01214556, - "spread": 0.0198645, - "score": 0.02328332 + "bias": 0.01208236, + "spread": 0.01575481, + "score": 0.01985441 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00612724, - "spread": 0.00621449, - "score": 0.00872714 + "bias": -0.00414858, + "spread": 0.00723693, + "score": 0.00834169 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": -0.00097568, - "spread": 0.00277692, - "score": 0.00294334 + "bias": -0.00072102, + "spread": 0.00342953, + "score": 0.0035045 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00257132, - "spread": 0.00310943, - "score": 0.00403488 + "bias": -0.00180748, + "spread": 0.00314107, + "score": 0.00362399 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00220205, - "spread": 0.00315208, - "score": 0.00384508 + "bias": -0.00064815, + "spread": 0.00486269, + "score": 0.00490569 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00368803, - "spread": 0.00463304, - "score": 0.00592171 + "bias": -0.00079664, + "spread": 0.00419678, + "score": 0.00427172 }, { "profile": "ti_dependent_cp", @@ -16594,117 +16594,117 @@ "campaign_months": 2, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.02435963, - "spread": 0.02508437, - "score": 0.03496594 + "bias": -0.02096286, + "spread": 0.02928283, + "score": 0.03601285 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00526507, - "spread": 0.0025222, - "score": 0.00583802 + "bias": -0.00403066, + "spread": 0.00168699, + "score": 0.00436946 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00160853, - "spread": 0.00367129, - "score": 0.00400821 + "bias": 0.00168624, + "spread": 0.00446718, + "score": 0.00477484 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00053596, - "spread": 0.00569485, - "score": 0.00572001 + "bias": 0.0008049, + "spread": 0.00528552, + "score": 0.00534646 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00478334, - "spread": 0.00462228, - "score": 0.00665175 + "bias": -0.0006854, + "spread": 0.0079422, + "score": 0.00797172 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00583272, - "spread": 0.07780393, - "score": 0.07802225 + "bias": -0.00570001, + "spread": 0.06879366, + "score": 0.06902939 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.11983152, - "spread": 0.17143549, - "score": 0.20916434 + "bias": 0.119551, + "spread": 0.16904304, + "score": 0.20704587 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.03152478, - "spread": 0.02074932, - "score": 0.03774051 + "bias": 0.02183968, + "spread": 0.03905462, + "score": 0.04474634 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.04137209, - "spread": 0.00609443, - "score": 0.04181856 + "bias": 2.61893087, + "spread": 4.45975006, + "score": 5.17186325 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.02203969, - "spread": 0.02171799, - "score": 0.03094219 + "bias": -0.00477998, + "spread": 0.05654173, + "score": 0.05674342 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00543748, - "spread": 0.00174667, - "score": 0.00571113 + "bias": -0.00357254, + "spread": 0.00392549, + "score": 0.00530778 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00144017, - "spread": 0.00418861, - "score": 0.00442928 + "bias": 0.00240255, + "spread": 0.00496977, + "score": 0.00552004 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.0013594, - "spread": 0.003147, - "score": 0.00342806 + "bias": -0.0014547, + "spread": 0.00339141, + "score": 0.00369023 }, { "profile": "ti_dependent_cp", @@ -16729,9 +16729,9 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": 0.01116098, - "spread": 0.05631046, - "score": 0.05740588 + "bias": 0.00641901, + "spread": 0.04982533, + "score": 0.05023711 }, { "profile": "ti_dependent_cp", @@ -16765,90 +16765,90 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00168534, - "spread": 0.01298232, - "score": 0.01309126 + "bias": 0.00375333, + "spread": 0.01214561, + "score": 0.01271233 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00036062, - "spread": 0.00378958, - "score": 0.0038067 + "bias": -0.00234275, + "spread": 0.00564078, + "score": 0.00610793 }, { "profile": "ti_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00022775, - "spread": 0.00277272, - "score": 0.00278206 + "bias": 0.00085192, + "spread": 0.00287139, + "score": 0.00299511 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "overall", "condition_bin": "overall", - "bias": 0.00182779, - "spread": 0.0018808, - "score": 0.00262264 + "bias": 0.00119356, + "spread": 0.00235786, + "score": 0.00264274 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00712037, - "spread": 0.01462503, - "score": 0.01626625 + "bias": 0.01021804, + "spread": 0.01109793, + "score": 0.01508551 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00403999, - "spread": 0.00452272, - "score": 0.00606436 + "bias": -0.00122857, + "spread": 0.0022225, + "score": 0.00253946 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": -0.00164356, - "spread": 0.00106104, - "score": 0.0019563 + "bias": -0.00187602, + "spread": 0.00121953, + "score": 0.00223757 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00090395, - "spread": 0.00304782, - "score": 0.00317905 + "bias": 0.00112776, + "spread": 0.00377795, + "score": 0.00394268 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00596894, - "spread": 0.00583542, - "score": 0.00834747 + "bias": 0.00208915, + "spread": 0.00730978, + "score": 0.00760247 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00254026, - "spread": 0.00432635, - "score": 0.00501699 + "bias": 0.00020739, + "spread": 0.00366209, + "score": 0.00366796 }, { "profile": "ti_dependent_cp", @@ -16864,126 +16864,126 @@ "campaign_months": 3, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00506049, - "spread": 0.01359558, - "score": 0.01450684 + "bias": -0.00696324, + "spread": 0.01286695, + "score": 0.01463028 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00181054, - "spread": 0.00268386, - "score": 0.00323746 + "bias": -0.00091248, + "spread": 0.00268548, + "score": 0.00283627 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00312489, - "spread": 0.00291199, - "score": 0.00427137 + "bias": 0.00216873, + "spread": 0.00303008, + "score": 0.00372623 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00435328, - "spread": 0.00566166, - "score": 0.00714181 + "bias": 0.0029368, + "spread": 0.00466762, + "score": 0.00551466 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01338806, - "spread": 0.00540999, - "score": 0.01443981 + "bias": 0.00598378, + "spread": 0.00687509, + "score": 0.00911441 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.00826577, - "spread": 0.0295407, - "score": 0.03067533 + "bias": -0.00179062, + "spread": 0.03284251, + "score": 0.03289128 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.02484169, - "spread": 0.04097759, - "score": 0.04791944 + "bias": 0.01845873, + "spread": 0.06198841, + "score": 0.06467834 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": -0.12702858, - "spread": 0.28316369, - "score": 0.31035131 + "bias": -0.13445121, + "spread": 0.29122967, + "score": 0.3207676 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.04386513, - "spread": 0.00531974, - "score": 0.04418653 + "bias": 0.0432309, + "spread": 0.00619905, + "score": 0.04367309 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.00109253, - "spread": 0.02347432, - "score": 0.02349973 + "bias": -0.04688849, + "spread": 0.02998379, + "score": 0.05565571 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00286849, - "spread": 0.00099094, - "score": 0.00303483 + "bias": -0.00260414, + "spread": 0.00204333, + "score": 0.0033101 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00418182, - "spread": 0.00315645, - "score": 0.00523935 + "bias": 0.00417774, + "spread": 0.00372452, + "score": 0.00559692 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00188453, - "spread": 0.00375254, - "score": 0.00419916 + "bias": 0.00114405, + "spread": 0.00433766, + "score": 0.00448599 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": -0.00041425, - "spread": 0.00036013, - "score": 0.00054891 + "bias": -0.00029749, + "spread": 0.00022051, + "score": 0.0003703 }, { "profile": "ti_dependent_cp", @@ -16999,9 +16999,9 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": 0.00921032, - "spread": 0.03671825, - "score": 0.03785577 + "bias": 0.01161039, + "spread": 0.02757517, + "score": 0.02991974 }, { "profile": "ti_dependent_cp", @@ -17035,243 +17035,243 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00170591, - "spread": 0.01277076, - "score": 0.0128842 + "bias": -8.623e-05, + "spread": 0.01019931, + "score": 0.01019968 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00633656, - "spread": 0.00533368, - "score": 0.00828252 + "bias": 0.00278509, + "spread": 0.00517723, + "score": 0.00587881 }, { "profile": "ti_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00068106, - "spread": 0.00420468, - "score": 0.00425948 + "bias": 0.00097897, + "spread": 0.00393531, + "score": 0.00405525 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "overall", "condition_bin": "overall", - "bias": 0.00149274, - "spread": 0.00157831, - "score": 0.0021724 + "bias": 0.00126469, + "spread": 0.00157636, + "score": 0.00202098 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00138735, - "spread": 0.00268416, - "score": 0.0030215 + "bias": 0.00151268, + "spread": 0.00542895, + "score": 0.00563575 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00200405, - "spread": 0.00527101, - "score": 0.00563912 + "bias": 0.0010071, + "spread": 0.00314574, + "score": 0.00330302 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": -0.00136334, - "spread": 0.00278017, - "score": 0.00309645 + "bias": -0.0026043, + "spread": 0.00288148, + "score": 0.00388398 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00121929, - "spread": 0.002557, - "score": 0.00283283 + "bias": 0.00083799, + "spread": 0.00309592, + "score": 0.00320732 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00139595, - "spread": 0.00284444, - "score": 0.00316852 + "bias": 0.00159639, + "spread": 0.00270454, + "score": 0.00314055 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00295415, - "spread": 0.00641998, - "score": 0.00706705 + "bias": 0.00121666, + "spread": 0.00680359, + "score": 0.00691152 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.06091945, - "spread": 0.00952155, - "score": 0.06165906 + "bias": -0.0609558, + "spread": 0.00986456, + "score": 0.06174884 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00411951, - "spread": 0.01151784, - "score": 0.01223237 + "bias": -0.00341329, + "spread": 0.00816048, + "score": 0.00884557 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00132276, - "spread": 0.00191204, - "score": 0.00232499 + "bias": -0.00073691, + "spread": 0.00199144, + "score": 0.00212341 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00232, - "spread": 0.00143364, - "score": 0.00272722 + "bias": 0.00200095, + "spread": 0.0009811, + "score": 0.00222853 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00278105, - "spread": 0.00231529, - "score": 0.00361868 + "bias": 0.00216673, + "spread": 0.00149594, + "score": 0.00263298 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01281398, - "spread": 0.0083279, - "score": 0.01528241 + "bias": 0.00926954, + "spread": 0.01125823, + "score": 0.01458328 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.02043173, - "spread": 0.02205106, - "score": 0.03006169 + "bias": 0.01030473, + "spread": 0.01407959, + "score": 0.01744771 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.01756649, - "spread": 0.02741144, - "score": 0.03255716 + "bias": -0.03238896, + "spread": 0.03052594, + "score": 0.04450705 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.07384845, - "spread": 0.1684744, - "score": 0.18394895 + "bias": 0.05380314, + "spread": 0.17999409, + "score": 0.18786338 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.04288149, - "spread": 0.00474671, - "score": 0.0431434 + "bias": 0.04265344, + "spread": 0.00485277, + "score": 0.0429286 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.00130036, - "spread": 0.01875255, - "score": 0.01879758 + "bias": -0.01772006, + "spread": 0.03140229, + "score": 0.03605696 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00137204, - "spread": 0.00093365, - "score": 0.00165958 + "bias": -0.00138811, + "spread": 0.00058842, + "score": 0.00150768 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00323641, - "spread": 0.00242796, - "score": 0.0040459 + "bias": 0.00249562, + "spread": 0.00255454, + "score": 0.00357125 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.0009763, - "spread": 0.00269141, - "score": 0.00286301 + "bias": 0.00037575, + "spread": 0.00294925, + "score": 0.00297309 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00072431, - "spread": 0.00219193, - "score": 0.0023085 + "bias": 8.59e-05, + "spread": 0.00253139, + "score": 0.00253285 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": -0.00064002, - "spread": 0.00075377, - "score": 0.00098883 + "bias": -0.00079664, + "spread": 0.00080478, + "score": 0.00113239 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.01230979, - "spread": 0.03187957, - "score": 0.03417364 + "bias": -0.01145368, + "spread": 0.0269059, + "score": 0.02924234 }, { "profile": "ti_dependent_cp", @@ -17305,252 +17305,252 @@ "campaign_months": 6, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00316498, - "spread": 0.00543784, - "score": 0.00629184 + "bias": 0.00288618, + "spread": 0.00458103, + "score": 0.00541441 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00092912, - "spread": 0.0030107, - "score": 0.00315081 + "bias": 0.00074183, + "spread": 0.00248488, + "score": 0.00259325 }, { "profile": "ti_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00239937, - "spread": 0.00547424, - "score": 0.00597698 + "bias": 0.00265646, + "spread": 0.00550732, + "score": 0.00611452 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "overall", "condition_bin": "overall", - "bias": 0.00064343, - "spread": 0.00185842, - "score": 0.00196665 + "bias": 0.00029947, + "spread": 0.00169165, + "score": 0.00171795 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00169042, - "spread": 0.00624879, - "score": 0.00647339 + "bias": 0.00064976, + "spread": 0.00515944, + "score": 0.00520019 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00466422, - "spread": 0.00393206, - "score": 0.0061005 + "bias": -0.00274994, + "spread": 0.00339033, + "score": 0.00436537 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": -0.00157673, - "spread": 0.00134493, - "score": 0.00207241 + "bias": -0.00098037, + "spread": 0.00164792, + "score": 0.00191749 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00055636, - "spread": 0.001543, - "score": 0.00164024 + "bias": 0.00024439, + "spread": 0.00162194, + "score": 0.00164025 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00284066, - "spread": 0.00293526, - "score": 0.00408474 + "bias": 0.00177441, + "spread": 0.00269495, + "score": 0.00322665 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00061851, - "spread": 0.00309986, - "score": 0.00316097 + "bias": -0.00067889, + "spread": 0.00380323, + "score": 0.00386334 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.03277136, - "spread": 0.04926048, - "score": 0.0591655 + "bias": -0.03308193, + "spread": 0.04942818, + "score": 0.05947738 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00119263, - "spread": 0.00416776, - "score": 0.00433504 + "bias": -0.00052228, + "spread": 0.00234888, + "score": 0.00240625 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00046522, - "spread": 0.00176184, - "score": 0.00182223 + "bias": -0.00054743, + "spread": 0.00171668, + "score": 0.00180185 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00035355, - "spread": 0.0014546, - "score": 0.00149695 + "bias": -0.00054502, + "spread": 0.00124734, + "score": 0.00136121 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": 0.00324848, - "spread": 0.00304201, - "score": 0.00445044 + "bias": 0.00224334, + "spread": 0.00300737, + "score": 0.00375191 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.01498092, - "spread": 0.00823609, - "score": 0.01709565 + "bias": 0.01131329, + "spread": 0.00512016, + "score": 0.01241799 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": 0.01090589, - "spread": 0.03009592, - "score": 0.03201098 + "bias": 0.01277932, + "spread": 0.02972981, + "score": 0.03236005 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.00261647, - "spread": 0.02028444, - "score": 0.02045249 + "bias": -0.00475235, + "spread": 0.03231113, + "score": 0.03265875 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.03986131, - "spread": 0.23261264, - "score": 0.23600332 + "bias": 0.02124497, + "spread": 0.22272736, + "score": 0.2237383 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -0.07560464, - "spread": 0.13700402, - "score": 0.15648055 + "bias": -0.02166676, + "spread": 0.0725639, + "score": 0.07572957 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.00767164, - "spread": 0.04080506, - "score": 0.04151996 + "bias": -0.01271292, + "spread": 0.0350136, + "score": 0.03725011 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00288865, - "spread": 0.00262754, - "score": 0.0039049 + "bias": -0.00272376, + "spread": 0.00159935, + "score": 0.00315861 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00347012, - "spread": 0.00113911, - "score": 0.0036523 + "bias": 0.00285733, + "spread": 0.00124565, + "score": 0.00311704 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -7.837e-05, - "spread": 0.00165124, - "score": 0.0016531 + "bias": -0.00031754, + "spread": 0.00166165, + "score": 0.00169172 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00056999, - "spread": 0.00122639, - "score": 0.00135237 + "bias": 0.00015257, + "spread": 0.00129239, + "score": 0.00130137 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": -0.00131699, - "spread": 0.00241645, - "score": 0.00275203 + "bias": -0.00166244, + "spread": 0.00263298, + "score": 0.00311389 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.00090957, - "spread": 0.0203952, - "score": 0.02041547 + "bias": -0.00044453, + "spread": 0.01168826, + "score": 0.01169671 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": -0.00435303, - "spread": 0.00351581, - "score": 0.00559551 + "bias": -0.00520906, + "spread": 0.00414281, + "score": 0.00665561 }, { "profile": "ti_dependent_cp", @@ -17575,90 +17575,90 @@ "campaign_months": 12, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00185326, - "spread": 0.00632823, - "score": 0.00659401 + "bias": 0.00101128, + "spread": 0.00550618, + "score": 0.00559827 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00340927, - "spread": 0.00329596, - "score": 0.00474199 + "bias": 0.00307861, + "spread": 0.00284487, + "score": 0.00419179 }, { "profile": "ti_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00018085, - "spread": 0.00287841, - "score": 0.00288409 + "bias": -0.00078253, + "spread": 0.00310079, + "score": 0.003198 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "overall", "condition_bin": "overall", - "bias": -0.00316987, - "spread": 0.00237273, - "score": 0.00395954 + "bias": -0.00361742, + "spread": 0.00350211, + "score": 0.00503493 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.01564405, - "spread": 0.02706897, - "score": 0.03126444 + "bias": 0.01245411, + "spread": 0.03026344, + "score": 0.03272584 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00234581, - "spread": 0.00916789, - "score": 0.00946325 + "bias": -0.00287813, + "spread": 0.00605307, + "score": 0.00670249 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00301032, - "spread": 0.01126004, - "score": 0.01165549 + "bias": 0.00161355, + "spread": 0.01165181, + "score": 0.01176301 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 0.00010875, - "spread": 0.00391333, - "score": 0.00391484 + "bias": 0.00011244, + "spread": 0.0037155, + "score": 0.0037172 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": -0.00460778, - "spread": 0.00965936, - "score": 0.0107021 + "bias": -0.00909543, + "spread": 0.01246862, + "score": 0.01543352 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.01059292, - "spread": 0.00924604, - "score": 0.01406056 + "bias": -0.00752743, + "spread": 0.00773436, + "score": 0.0107927 }, { "profile": "ws_dependent_cp", @@ -17674,117 +17674,117 @@ "campaign_months": 1, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00909171, - "spread": 0.01093162, - "score": 0.01421828 + "bias": -0.00485589, + "spread": 0.01290752, + "score": 0.01379071 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00309674, - "spread": 0.00214681, - "score": 0.0037681 + "bias": -0.00382398, + "spread": 0.00206877, + "score": 0.00434772 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": -0.00143072, - "spread": 0.00363562, - "score": 0.00390701 + "bias": -0.00135678, + "spread": 0.00431594, + "score": 0.00452418 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00435745, - "spread": 0.00389633, - "score": 0.00584541 + "bias": -0.00493707, + "spread": 0.00446114, + "score": 0.00665405 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00630039, - "spread": 0.00323323, - "score": 0.00708158 + "bias": -0.01396364, + "spread": 0.00638514, + "score": 0.01535425 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.06908928, - "spread": 0.03213426, - "score": 0.07619671 + "bias": -0.06585935, + "spread": 0.02737276, + "score": 0.07132126 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.01449343, - "spread": 0.17802062, - "score": 0.17860963 + "bias": 0.01404587, + "spread": 0.17919576, + "score": 0.1797454 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 1.28993168, - "spread": 2.21221129, - "score": 2.56082067 + "bias": 1.28948412, + "spread": 2.21256974, + "score": 2.56090495 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": -2.02811385, - "spread": 3.58814655, - "score": 4.12165519 + "bias": -2.02856141, + "spread": 3.58795537, + "score": 4.121709 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.01564106, - "spread": 0.05533218, - "score": 0.05750037 + "bias": -0.00816119, + "spread": 0.05265456, + "score": 0.05328328 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00188547, - "spread": 0.00929797, - "score": 0.00948721 + "bias": -0.00250069, + "spread": 0.00862093, + "score": 0.0089763 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00276304, - "spread": 0.00889751, - "score": 0.00931665 + "bias": 0.00235626, + "spread": 0.00962377, + "score": 0.00990802 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00105854, - "spread": 0.00183345, - "score": 0.00211709 + "bias": -0.00096181, + "spread": 0.0016659, + "score": 0.00192361 }, { "profile": "ws_dependent_cp", @@ -17809,9 +17809,9 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.04306485, - "spread": 0.08415965, - "score": 0.09453797 + "bias": -0.04940717, + "spread": 0.11959625, + "score": 0.12939989 }, { "profile": "ws_dependent_cp", @@ -17845,90 +17845,90 @@ "campaign_months": 1, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00507191, - "spread": 0.01904063, - "score": 0.01970456 + "bias": -0.00142004, + "spread": 0.01602094, + "score": 0.01608375 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00663974, - "spread": 0.00513429, - "score": 0.00839327 + "bias": -0.00940616, + "spread": 0.00760273, + "score": 0.01209452 }, { "profile": "ws_dependent_cp", "campaign_months": 1, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00352009, - "spread": 0.00529956, - "score": 0.00636211 + "bias": -0.00352802, + "spread": 0.0038623, + "score": 0.00523109 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "overall", "condition_bin": "overall", - "bias": -0.00126705, - "spread": 0.00166538, - "score": 0.00209258 + "bias": -0.00030871, + "spread": 0.0025845, + "score": 0.00260287 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.01068341, - "spread": 0.02227797, - "score": 0.02470715 + "bias": 0.01273498, + "spread": 0.01595358, + "score": 0.02041315 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00509417, - "spread": 0.00579142, - "score": 0.00771305 + "bias": -0.00350041, + "spread": 0.00674098, + "score": 0.00759564 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00436447, - "spread": 0.00496806, - "score": 0.00661289 + "bias": 0.00413017, + "spread": 0.00531586, + "score": 0.00673177 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00378302, - "spread": 0.00279686, - "score": 0.00470464 + "bias": -0.00287999, + "spread": 0.00215457, + "score": 0.00359674 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00154083, - "spread": 0.00412255, - "score": 0.00440109 + "bias": -0.00052734, + "spread": 0.00641824, + "score": 0.00643986 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00510113, - "spread": 0.00664115, - "score": 0.00837415 + "bias": -0.000927, + "spread": 0.00489819, + "score": 0.00498514 }, { "profile": "ws_dependent_cp", @@ -17944,117 +17944,117 @@ "campaign_months": 2, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": -0.00763642, - "spread": 0.01463878, - "score": 0.01651087 + "bias": -0.00521412, + "spread": 0.01741581, + "score": 0.01817959 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": -0.00350567, - "spread": 0.00277728, - "score": 0.00447247 + "bias": -0.00190952, + "spread": 0.00200896, + "score": 0.00277167 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00284194, - "spread": 0.00334086, - "score": 0.00438612 + "bias": 0.00320404, + "spread": 0.00430567, + "score": 0.005367 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00472493, - "spread": 0.00449398, - "score": 0.0065208 + "bias": -0.00288952, + "spread": 0.00416095, + "score": 0.00506585 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00919414, - "spread": 0.00823191, - "score": 0.01234085 + "bias": -0.01302042, + "spread": 0.00889623, + "score": 0.01576941 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.03212832, - "spread": 0.0870391, - "score": 0.09277949 + "bias": -0.02584726, + "spread": 0.08364273, + "score": 0.08754535 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": 0.05495255, - "spread": 0.20704202, - "score": 0.2142106 + "bias": 0.06313702, + "spread": 0.20539855, + "score": 0.21488333 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.13909671, - "spread": 0.20217877, - "score": 0.24540609 + "bias": 0.12622531, + "spread": 0.21538892, + "score": 0.24965019 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.24222677, - "spread": 0.3647096, - "score": 0.43782063 + "bias": 2.83547595, + "spread": 4.41826643, + "score": 5.24985734 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.01647143, - "spread": 0.02177919, - "score": 0.02730644 + "bias": -0.00378916, + "spread": 0.05003978, + "score": 0.05018303 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": -0.00166229, - "spread": 0.00227642, - "score": 0.00281874 + "bias": 0.00042914, + "spread": 0.00347523, + "score": 0.00350163 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00033015, - "spread": 0.00340086, - "score": 0.00341685 + "bias": 0.00156409, + "spread": 0.00465527, + "score": 0.004911 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00173242, - "spread": 0.0030841, - "score": 0.00353737 + "bias": -0.0015374, + "spread": 0.0029719, + "score": 0.00334602 }, { "profile": "ws_dependent_cp", @@ -18079,9 +18079,9 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.00966759, - "spread": 0.05994207, - "score": 0.06071667 + "bias": -0.00810072, + "spread": 0.04852, + "score": 0.04919158 }, { "profile": "ws_dependent_cp", @@ -18115,90 +18115,90 @@ "campaign_months": 2, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00071206, - "spread": 0.01380538, - "score": 0.01382373 + "bias": 0.00212148, + "spread": 0.01244298, + "score": 0.01262253 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00190441, - "spread": 0.00424775, - "score": 0.00465512 + "bias": -0.00327738, + "spread": 0.00554994, + "score": 0.00644539 }, { "profile": "ws_dependent_cp", "campaign_months": 2, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.00068876, - "spread": 0.00242545, - "score": 0.00252135 + "bias": 0.00063533, + "spread": 0.00224468, + "score": 0.00233286 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "overall", "condition_bin": "overall", - "bias": 0.00183128, - "spread": 0.00185698, - "score": 0.00260805 + "bias": 0.00101019, + "spread": 0.00205834, + "score": 0.00229287 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": 0.00670495, - "spread": 0.01465742, - "score": 0.0161182 + "bias": 0.00982432, + "spread": 0.00996043, + "score": 0.01399026 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00325235, - "spread": 0.00266901, - "score": 0.0042073 + "bias": -0.00139144, + "spread": 0.00170517, + "score": 0.00220084 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00261509, - "spread": 0.00128774, - "score": 0.00291496 + "bias": 0.00215405, + "spread": 0.00216765, + "score": 0.00305592 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00029282, - "spread": 0.00309734, - "score": 0.00311115 + "bias": -0.00052243, + "spread": 0.00364944, + "score": 0.00368664 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00478545, - "spread": 0.00728669, - "score": 0.00871759 + "bias": 0.00121512, + "spread": 0.00645362, + "score": 0.00656702 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00211698, - "spread": 0.00382002, - "score": 0.00436739 + "bias": 5.871e-05, + "spread": 0.00400003, + "score": 0.00400046 }, { "profile": "ws_dependent_cp", @@ -18214,126 +18214,126 @@ "campaign_months": 3, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00225894, - "spread": 0.01066002, - "score": 0.01089673 + "bias": 0.00052292, + "spread": 0.00747584, + "score": 0.0074941 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.0003161, - "spread": 0.00263877, - "score": 0.00265763 + "bias": 0.00052671, + "spread": 0.00218135, + "score": 0.00224404 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00404667, - "spread": 0.00278128, - "score": 0.0049103 + "bias": 0.00303713, + "spread": 0.00274475, + "score": 0.00409363 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00035083, - "spread": 0.00492958, - "score": 0.00494205 + "bias": -0.00115342, + "spread": 0.00310012, + "score": 0.00330774 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00061215, - "spread": 0.0064336, - "score": 0.00646265 + "bias": -0.0067943, + "spread": 0.00768658, + "score": 0.01025894 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.0123531, - "spread": 0.03549997, - "score": 0.03758785 + "bias": -0.02747497, + "spread": 0.03393175, + "score": 0.04366048 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.01901126, - "spread": 0.03850116, - "score": 0.04293911 + "bias": -0.02228348, + "spread": 0.05547893, + "score": 0.05978683 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": -0.51467847, - "spread": 0.87928925, - "score": 1.0188442 + "bias": -0.52248544, + "spread": 0.88694495, + "score": 1.02939904 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.10864009, - "spread": 0.1286496, - "score": 0.16838465 + "bias": 0.11200427, + "spread": 0.13410987, + "score": 0.17472955 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.00275083, - "spread": 0.03173845, - "score": 0.03185743 + "bias": -0.03924513, + "spread": 0.02457721, + "score": 0.04630571 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00089828, - "spread": 0.00061721, - "score": 0.00108988 + "bias": 0.00088941, + "spread": 0.00169319, + "score": 0.00191257 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00260599, - "spread": 0.00294118, - "score": 0.0039296 + "bias": 0.00260546, + "spread": 0.00345821, + "score": 0.00432985 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00146263, - "spread": 0.00387517, - "score": 0.00414201 + "bias": 0.00034454, + "spread": 0.00407023, + "score": 0.00408479 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": -0.00018535, - "spread": 0.00032103, - "score": 0.0003707 + "bias": 5.717e-05, + "spread": 9.901e-05, + "score": 0.00011433 }, { "profile": "ws_dependent_cp", @@ -18349,9 +18349,9 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.00786942, - "spread": 0.03997929, - "score": 0.04074643 + "bias": -0.00545445, + "spread": 0.02992731, + "score": 0.03042031 }, { "profile": "ws_dependent_cp", @@ -18385,243 +18385,243 @@ "campaign_months": 3, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.00118195, - "spread": 0.01425212, - "score": 0.01430105 + "bias": -0.00217302, + "spread": 0.00962426, + "score": 0.00986652 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00514818, - "spread": 0.00595623, - "score": 0.00787276 + "bias": 0.00128001, + "spread": 0.00535572, + "score": 0.00550656 }, { "profile": "ws_dependent_cp", "campaign_months": 3, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.00011576, - "spread": 0.00427549, - "score": 0.00427706 + "bias": 3.829e-05, + "spread": 0.00390691, + "score": 0.0039071 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "overall", "condition_bin": "overall", - "bias": 0.00152731, - "spread": 0.00157213, - "score": 0.00219186 + "bias": 0.00135532, + "spread": 0.0015151, + "score": 0.00203283 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.00049968, - "spread": 0.00236714, - "score": 0.0024193 + "bias": 0.00024378, + "spread": 0.00539147, + "score": 0.00539698 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00033225, - "spread": 0.00420217, - "score": 0.00421528 + "bias": 0.00090703, + "spread": 0.00286478, + "score": 0.00300494 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00279013, - "spread": 0.00361244, - "score": 0.00456449 + "bias": 0.0013055, + "spread": 0.00265218, + "score": 0.00295608 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": 9.956e-05, - "spread": 0.00248941, - "score": 0.0024914 + "bias": -8.82e-06, + "spread": 0.0031851, + "score": 0.00318511 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.0008001, - "spread": 0.00239792, - "score": 0.00252788 + "bias": 0.00068231, + "spread": 0.00212599, + "score": 0.00223279 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": 0.00109923, - "spread": 0.00623894, - "score": 0.00633504 + "bias": 0.00192298, + "spread": 0.00587859, + "score": 0.00618511 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.06339893, - "spread": 0.01263045, - "score": 0.06464482 + "bias": -0.06338114, + "spread": 0.01306065, + "score": 0.06471283 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00043278, - "spread": 0.01084267, - "score": 0.01085131 + "bias": -0.00021458, + "spread": 0.00732675, + "score": 0.00732989 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00090561, - "spread": 0.00162394, - "score": 0.00185938 + "bias": 0.00124335, + "spread": 0.0015123, + "score": 0.0019578 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00315884, - "spread": 0.00162028, - "score": 0.00355015 + "bias": 0.00288867, + "spread": 0.00122373, + "score": 0.00313719 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.00176909, - "spread": 0.0026478, - "score": 0.00318442 + "bias": -0.00133191, + "spread": 0.00163159, + "score": 0.0021062 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": -0.00083703, - "spread": 0.00981064, - "score": 0.00984628 + "bias": -0.00338674, + "spread": 0.01146878, + "score": 0.01195839 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00027735, - "spread": 0.02718334, - "score": 0.02718475 + "bias": -0.00777909, + "spread": 0.01993518, + "score": 0.0213992 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.0619254, - "spread": 0.03102378, - "score": 0.06926204 + "bias": -0.07228875, + "spread": 0.02313304, + "score": 0.07589994 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.09457439, - "spread": 0.31705364, - "score": 0.33085847 + "bias": 0.08289865, + "spread": 0.33939689, + "score": 0.34937435 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.07663987, - "spread": 0.09112929, - "score": 0.11907232 + "bias": 0.07646788, + "spread": 0.09075947, + "score": 0.11867863 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": 0.00030753, - "spread": 0.01669991, - "score": 0.01670274 + "bias": -0.01435106, + "spread": 0.04180949, + "score": 0.04420392 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.0026057, - "spread": 0.00130357, - "score": 0.00291358 + "bias": 0.00228728, + "spread": 0.00069702, + "score": 0.00239112 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00214592, - "spread": 0.00205528, - "score": 0.00297139 + "bias": 0.00157266, + "spread": 0.00221776, + "score": 0.00271878 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": 0.00070511, - "spread": 0.00254571, - "score": 0.00264156 + "bias": 0.00017963, + "spread": 0.00291399, + "score": 0.00291953 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00117214, - "spread": 0.00235787, - "score": 0.00263315 + "bias": 0.00060862, + "spread": 0.0023027, + "score": 0.00238178 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": 0.00022097, - "spread": 0.00038273, - "score": 0.00044194 + "bias": 3.291e-05, + "spread": 5.701e-05, + "score": 6.583e-05 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.04421435, - "spread": 0.0501345, - "score": 0.06684592 + "bias": -0.03552171, + "spread": 0.03879847, + "score": 0.05260336 }, { "profile": "ws_dependent_cp", @@ -18655,252 +18655,252 @@ "campaign_months": 6, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": 0.00048858, - "spread": 0.0057116, - "score": 0.00573246 + "bias": 0.00080547, + "spread": 0.0049944, + "score": 0.00505893 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": -0.00072828, - "spread": 0.00327307, - "score": 0.00335312 + "bias": -0.00081976, + "spread": 0.00257581, + "score": 0.00270311 }, { "profile": "ws_dependent_cp", "campaign_months": 6, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": 0.0016743, - "spread": 0.00559151, - "score": 0.00583681 + "bias": 0.00209339, + "spread": 0.00546902, + "score": 0.00585598 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "overall", "condition_bin": "overall", - "bias": 0.00068629, - "spread": 0.00184793, - "score": 0.00197125 + "bias": 0.00042233, + "spread": 0.0016521, + "score": 0.00170523 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(-230.0, 230.0]", - "bias": -0.00108241, - "spread": 0.00623434, - "score": 0.00632761 + "bias": 1.507e-05, + "spread": 0.00523949, + "score": 0.00523951 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(1150.0, 1610.0]", - "bias": -0.00288707, - "spread": 0.00357957, - "score": 0.00459875 + "bias": -0.00223425, + "spread": 0.00271358, + "score": 0.00351503 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(1610.0, 2070.0]", - "bias": 0.00186092, - "spread": 0.00134667, - "score": 0.00229707 + "bias": 0.00179698, + "spread": 0.00152481, + "score": 0.00235673 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(2070.0, 2530.0]", - "bias": -0.00013809, - "spread": 0.00147693, - "score": 0.00148337 + "bias": -0.00031847, + "spread": 0.00165836, + "score": 0.00168867 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(230.0, 690.0]", - "bias": 0.00264107, - "spread": 0.00365638, - "score": 0.00451047 + "bias": 0.0019994, + "spread": 0.00226854, + "score": 0.00302388 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "power", "condition_bin": "(690.0, 1150.0]", - "bias": -0.00026697, - "spread": 0.00353568, - "score": 0.00354575 + "bias": -0.00067472, + "spread": 0.00373534, + "score": 0.00379579 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.0, 0.05]", - "bias": -0.03751521, - "spread": 0.04689147, - "score": 0.06005165 + "bias": -0.03776513, + "spread": 0.04706567, + "score": 0.06034387 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.05, 0.1]", - "bias": 0.00211723, - "spread": 0.00376712, - "score": 0.00432132 + "bias": 0.0022381, + "spread": 0.00183284, + "score": 0.00289282 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.1, 0.15]", - "bias": 0.00126919, - "spread": 0.0015279, - "score": 0.00198629 + "bias": 0.00107172, + "spread": 0.00135397, + "score": 0.00172679 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.15, 0.2]", - "bias": 0.00048182, - "spread": 0.00154796, - "score": 0.00162122 + "bias": 0.00016957, + "spread": 0.00134545, + "score": 0.00135609 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.2, 0.25]", - "bias": -0.0005041, - "spread": 0.00328488, - "score": 0.00332333 + "bias": -0.000657, + "spread": 0.0031535, + "score": 0.00322122 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.25, 0.3]", - "bias": 0.00262925, - "spread": 0.00694657, - "score": 0.0074275 + "bias": 0.00105387, + "spread": 0.00461877, + "score": 0.00473747 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.3, 0.35]", - "bias": -0.00948842, - "spread": 0.03334174, - "score": 0.03466557 + "bias": -0.00571004, + "spread": 0.03417995, + "score": 0.03465362 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.35, 0.4]", - "bias": -0.02164823, - "spread": 0.02098851, - "score": 0.03015234 + "bias": -0.02702415, + "spread": 0.03562146, + "score": 0.04471234 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.4, 0.45]", - "bias": 0.01713209, - "spread": 0.25572163, - "score": 0.25629487 + "bias": 0.01110306, + "spread": 0.23595097, + "score": 0.23621206 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ti", "condition_bin": "(0.45, 0.5]", - "bias": 0.50419051, - "spread": 0.57969355, - "score": 0.76827904 + "bias": 0.54575809, + "spread": 0.61058151, + "score": 0.81893935 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(0.0, 2.0]", - "bias": -0.00590864, - "spread": 0.04226358, - "score": 0.04267461 + "bias": -0.0174996, + "spread": 0.03904343, + "score": 0.04278581 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(10.0, 12.0]", - "bias": 0.00084424, - "spread": 0.00250798, - "score": 0.00264626 + "bias": 0.00058649, + "spread": 0.00161357, + "score": 0.00171685 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(12.0, 14.0]", - "bias": 0.00232505, - "spread": 0.00085995, - "score": 0.00247899 + "bias": 0.00191219, + "spread": 0.00113155, + "score": 0.00222191 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(14.0, 16.0]", - "bias": -0.00022674, - "spread": 0.0014535, - "score": 0.00147108 + "bias": -0.00047912, + "spread": 0.00167444, + "score": 0.00174164 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(16.0, 18.0]", - "bias": 0.00081464, - "spread": 0.00124414, - "score": 0.00148711 + "bias": 0.00043063, + "spread": 0.00137034, + "score": 0.00143641 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(18.0, 20.0]", - "bias": -6.589e-05, - "spread": 0.00160329, - "score": 0.00160465 + "bias": -9.035e-05, + "spread": 0.00155196, + "score": 0.00155459 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(2.0, 4.0]", - "bias": -0.02631469, - "spread": 0.02018141, - "score": 0.03316251 + "bias": -0.02377636, + "spread": 0.0182414, + "score": 0.02996771 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(20.0, 22.0]", - "bias": -0.00065711, - "spread": 0.00204492, - "score": 0.00214791 + "bias": -0.00117489, + "spread": 0.0027243, + "score": 0.00296685 }, { "profile": "ws_dependent_cp", @@ -18925,27 +18925,27 @@ "campaign_months": 12, "condition": "ws", "condition_bin": "(4.0, 6.0]", - "bias": -0.001117, - "spread": 0.00713144, - "score": 0.00721839 + "bias": -0.0010975, + "spread": 0.00599658, + "score": 0.00609619 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(6.0, 8.0]", - "bias": 0.00157068, - "spread": 0.00344089, - "score": 0.00378243 + "bias": 0.00176909, + "spread": 0.00250748, + "score": 0.00306873 }, { "profile": "ws_dependent_cp", "campaign_months": 12, "condition": "ws", "condition_bin": "(8.0, 10.0]", - "bias": -0.0006748, - "spread": 0.00318421, - "score": 0.00325493 + "bias": -0.00130119, + "spread": 0.0030523, + "score": 0.00331808 } ] } From 31a168ae32f084629f625cc05bd0a3cedbc93942 Mon Sep 17 00:00:00 2001 From: aclerc Date: Fri, 4 Sep 2026 07:07:27 +0100 Subject: [PATCH 18/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- docs/v1/issues_campaigns.md | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/v1/issues_campaigns.md b/docs/v1/issues_campaigns.md index 5aeb8156..da32e5a9 100644 --- a/docs/v1/issues_campaigns.md +++ b/docs/v1/issues_campaigns.md @@ -136,8 +136,8 @@ the robustness and campaign pieces first. **C8** (per-turbine change histories) the flat one. C7 (drop `rlearner`, ✅ done) was independent. **R5** (northing refinement) is deliberately outside this order: it is future work R1 identified but does not need. -**Done so far:** C0, W0, C7 and C1. **Next: C2** — with C1 in hand, decide how the -`CampaignSpec` reaches the methods before the demanding campaigns build on the seam. +**Done so far:** C0, W0, C7, C1, C2 and R1. **Next: R2–R4**, then C3, which inherits the +shared northing step R1 landed. --- @@ -449,6 +449,24 @@ Every R-issue shares a two-phase acceptance, run in **both prepost and toggle**: ## R1 — Northing errors (shared fix) +**Status:** ✅ Done (2026-09-03, PR #138). Shared step in `benchmarking/harness/northing.py`, +reached by both the campaign runner and the study path (which norths per replicate, discovering +for itself). `power_model`'s direction feature is on by default; all four frozen baselines +re-recorded. Two Done-when items were closed by decision rather than built, both recorded here: + +* **v0's arm is dropped.** The fixture never ran `V0BinnedMethod`, so "the step bites v0" is not + demonstrated. Accepted because the norther has been shown to track v0 three other ways: the + 21-turbine HoT farm-scale comparison, the SMARTEOLE road-test (uplift moves 0.05 pp / 0.01 pp), + and the natural probe, which rediscovered v0's published T05 table from the data. +* **The examples are not re-run with auto-northing.** Both ship + `optimize_northing_corrections=False`, and flipping it would add nothing: v0's auto path is + already covered by `tests/test_optimize_northing.py` (six tests through the adapter, including + injected changepoints) plus the three comparisons above, and the supplied-table path the + examples actually use is covered by the SMARTEOLE and WeDoWind end-to-end tests. W2 migrates + the examples to the v1 API, at which point `optimize_northing_corrections` ceases to exist for + them. + + **Goal:** wind-up recovers a known uplift despite a turbine's direction reference carrying a **step change** in its north calibration partway through the record. @@ -668,7 +686,9 @@ up. tracked **`docs/methodology.md`** describing the v1 method (the new source of truth; the PDF is exported from it at release). - Migrate or remove every example (`examples/`) to the v1 API; rewrite `README.md` for - v1. + v1. Northing changes shape in the move: the v0 examples pin a pre-computed table with + `optimize_northing_corrections=False`, whereas v1's shared step discovers by default, so a + migrated example should **show discovery** rather than port the pinned table across. - **Drop `benchmarking*` from packaging** (deferred from W0, where it stayed packaged only for a separate project that imports `toggle_specialist`): once that external dependency is gone, remove `benchmarking*` from `[tool.setuptools.packages.find]` From 0cc2792a99a66bfff8018432b489a102da6f89f2 Mon Sep 17 00:00:00 2001 From: aclerc Date: Fri, 4 Sep 2026 08:54:19 +0100 Subject: [PATCH 19/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- benchmarking/campaigns/placebo.py | 28 +++++------- benchmarking/campaigns/runner.py | 5 +++ benchmarking/harness/northing.py | 74 +++++++++++++++++++++++++------ docs/v1/issues_campaigns.md | 4 +- 4 files changed, 79 insertions(+), 32 deletions(-) diff --git a/benchmarking/campaigns/placebo.py b/benchmarking/campaigns/placebo.py index 76ba0900..7bc76371 100644 --- a/benchmarking/campaigns/placebo.py +++ b/benchmarking/campaigns/placebo.py @@ -27,13 +27,13 @@ mpl.use("Agg") # headless: the report writes plots without a display import pandas as pd -import yaml -from benchmarking.baselines.hot_context import NORTHING_YAML, build_hot_v0_context +from benchmarking.baselines.hot_context import build_hot_v0_context from benchmarking.campaigns.declaration import SyntheticCampaign from benchmarking.campaigns.methods import carried_forward_methods from benchmarking.campaigns.report import write_campaign_report from benchmarking.campaigns.runner import CampaignRunner +from benchmarking.harness.northing import era5_direction from benchmarking.synthetic import HOT_RATED_POWER_KW, ToggleSchedule from benchmarking.synthetic.sources.hill_of_towie import load_hot_metadata, load_hot_scada @@ -81,16 +81,6 @@ def default_output_root() -> Path: return root / "placebo" -def _north_offsets(turbines: Sequence[str]) -> list[tuple[str, pd.Timestamp, float]]: - """Step-applied north offsets for ``turbines`` from the vendored northing YAML (UTC).""" - data = yaml.safe_load(NORTHING_YAML.read_text()) - return [ - (str(name), pd.Timestamp(ts, tz="UTC"), float(offset)) - for (name, ts, offset) in data - if str(name) in set(turbines) - ] - - def _coords(turbines: Sequence[str]) -> dict[str, tuple[float, float]]: """Hill of Towie coordinates for ``turbines``.""" metadata = load_hot_metadata() @@ -136,7 +126,8 @@ def placebo_campaign( excluded_turbines=list(excluded), upgrades=[], coords=coords if coords is not None else dict.fromkeys(participating, (0.0, 0.0)), - north_offsets=_north_offsets(participating), + # discovered by the shared northing step, not supplied: the placebo exercises the norther + north_offsets=None, rated_power_kw=HOT_RATED_POWER_KW, analysis_period=placebo_analysis_period(mode), ) @@ -175,9 +166,10 @@ def run_placebo( campaign = placebo_campaign(mode, upgraded=upgraded, turbines=participating, coords=_coords(participating)) dataset = campaign.generate(scada_df) spec = campaign.spec() - # only the power model reads ERA5, and building the context fetches it, so the fast path - # stays free of the network dependency - era5 = build_hot_v0_context(wtg_names=participating).reanalysis_datasets[0].data if include_power_model else None + # ERA5 is needed whether or not the power model runs: it is the anchor the shared northing + # step discovers against. + era5 = build_hot_v0_context(wtg_names=participating).reanalysis_datasets[0].data + index = pd.DatetimeIndex(dataset.synthetic_df.index.unique()).sort_values() runner = CampaignRunner( spec, @@ -185,9 +177,11 @@ def run_placebo( build_methods=lambda wtg: carried_forward_methods( spec, out_dir=run_dir / wtg, - era5_hourly_df=era5, + era5_hourly_df=era5 if include_power_model else None, include_power_model=include_power_model, ), + era5_wd=era5_direction(era5, index), + northing_out_dir=run_dir / "northing", ) result = runner.run() write_campaign_report(result, dataset, out_dir=run_dir) diff --git a/benchmarking/campaigns/runner.py b/benchmarking/campaigns/runner.py index 20eb6932..52472c2e 100644 --- a/benchmarking/campaigns/runner.py +++ b/benchmarking/campaigns/runner.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Sequence + from pathlib import Path from benchmarking.campaigns.declaration import CampaignSpec from benchmarking.harness import Method, MethodInput, MethodOutput @@ -75,6 +76,7 @@ class CampaignRunner: needs none. :param northing_roles: the direction roles the shared step corrects :param northing_settings: how the shared step's changepoint search is bounded + :param northing_out_dir: where the shared step writes its plots when it discovers corrections """ def __init__( @@ -86,6 +88,7 @@ def __init__( era5_wd: pd.Series | None = None, northing_roles: Sequence[str] = DEFAULT_NORTHING_ROLES, northing_settings: NorthingSettings = DEFAULT_NORTHING, + northing_out_dir: Path | None = None, ) -> None: """Store the campaign, its data and the per-turbine method factory.""" self._spec = spec @@ -94,6 +97,7 @@ def __init__( self._era5_wd = era5_wd self._northing_roles = tuple(northing_roles) self._northing_settings = northing_settings + self._northing_out_dir = northing_out_dir def run(self) -> CampaignResult: """Run every applicable method on every upgraded turbine and aggregate to one headline.""" @@ -203,6 +207,7 @@ def _visible_dataset(self) -> SyntheticDataset: era5_wd=self._era5_wd, roles=self._northing_roles, settings=self._northing_settings, + out_dir=self._northing_out_dir, ) return replace( self._dataset, diff --git a/benchmarking/harness/northing.py b/benchmarking/harness/northing.py index 82ec1d91..728c2215 100644 --- a/benchmarking/harness/northing.py +++ b/benchmarking/harness/northing.py @@ -17,13 +17,16 @@ import logging from typing import TYPE_CHECKING +import matplotlib.pyplot as plt import numpy as np import pandas as pd from wind_up.northing import DEFAULT_NORTHING, NorthingSettings, apply_north_table, north_farm, yaw_usable +from wind_up.northing_plots import plot_northing, plot_northing_farm if TYPE_CHECKING: from collections.abc import Sequence + from pathlib import Path from benchmarking.synthetic import ColumnSchema @@ -33,6 +36,10 @@ # position and may be applied to further direction channels of the same turbine. DEFAULT_NORTHING_ROLES: tuple[str, ...] = ("nacelle_position",) +# The plots show the residual against reanalysis: it is the anchor available here, whereas the +# farm consensus pass 2 uses is internal to north_farm. +_PLOT_REFERENCE_NAME = "reanalysis" + # Open-Meteo's hub-height wind direction, the reanalysis anchor discovery is measured against. ERA5_WD_COL = "wind_direction_100m" @@ -115,6 +122,7 @@ def north_scada( era5_wd: pd.Series | None = None, roles: Sequence[str] = DEFAULT_NORTHING_ROLES, settings: NorthingSettings = DEFAULT_NORTHING, + out_dir: Path | None = None, ) -> pd.DataFrame: """Return ``scada_df`` with a north-calibrated companion column for each direction role. @@ -129,6 +137,8 @@ def north_scada( discovery. Required when ``north_offsets`` is ``None``. :param roles: the direction roles to write a ``northed_`` companion for :param settings: how the changepoint search is bounded, when discovering + :param out_dir: when given and corrections are discovered, the farm overview and one plot per + device are written here, so the correction can be judged rather than trusted :return: a copy of ``scada_df`` with ``columns.northed(role)`` added for each role """ columns.require_roles(roles) @@ -165,23 +175,23 @@ def north_scada( f"the north table for every role is derived from it. Columns present: {sorted(scada_df.columns)}" ) raise ValueError(msg) - tables = north_farm( - index, - direction_deg=_directions(scada_df, columns=columns, turbines=turbines, index=index, col=source), - usable=_usable_masks( - scada_df, - columns=columns, - turbines=turbines, - index=index, - reference_deg=reference, - rated_power_kw=rated_power_kw, - timebase_s=timebase_s, - ), - reanalysis_deg=reference, - settings=settings, + directions = _directions(scada_df, columns=columns, turbines=turbines, index=index, col=source) + usable = _usable_masks( + scada_df, + columns=columns, + turbines=turbines, + index=index, + reference_deg=reference, + rated_power_kw=rated_power_kw, + timebase_s=timebase_s, ) + tables = north_farm(index, direction_deg=directions, usable=usable, reanalysis_deg=reference, settings=settings) found = sum(len(t) - 1 for t in tables.values()) logger.info("discovered %d northing changepoint(s) across %d turbines", found, len(turbines)) + if out_dir is not None: + _write_northing_plots( + index, directions=directions, usable=usable, reference=reference, tables=tables, out_dir=out_dir + ) turbine_of = scada_df[columns.turbine].to_numpy() row_index = pd.DatetimeIndex(scada_df.index) @@ -198,6 +208,42 @@ def north_scada( return scada_df +def _write_northing_plots( + index: pd.DatetimeIndex, + *, + directions: dict[str, np.ndarray], + usable: dict[str, np.ndarray], + reference: np.ndarray, + tables: dict[str, pd.DataFrame], + out_dir: Path, +) -> None: + """Write the farm overview and one plot per device, then close the figures.""" + out_dir.mkdir(parents=True, exist_ok=True) + figure = plot_northing_farm( + index, + direction_deg=directions, + reference_deg=reference, + usable=usable, + north_tables=tables, + reference_name=_PLOT_REFERENCE_NAME, + out_dir=out_dir, + ) + plt.close(figure) + for device in sorted(directions): + figure = plot_northing( + index, + directions[device], + reference_deg=reference, + usable=usable[device], + north_table=tables[device], + device=device, + reference_name=_PLOT_REFERENCE_NAME, + out_dir=out_dir, + ) + plt.close(figure) + logger.info("wrote northing plots for %d device(s) to %s", len(directions), out_dir) + + def _timebase_seconds(index: pd.DatetimeIndex) -> float: """Return the frame's record length in seconds, from the most common gap between timestamps.""" if len(index) < 2: # noqa: PLR2004 - two timestamps are needed for a gap diff --git a/docs/v1/issues_campaigns.md b/docs/v1/issues_campaigns.md index da32e5a9..2ed1e1ed 100644 --- a/docs/v1/issues_campaigns.md +++ b/docs/v1/issues_campaigns.md @@ -452,7 +452,9 @@ Every R-issue shares a two-phase acceptance, run in **both prepost and toggle**: **Status:** ✅ Done (2026-09-03, PR #138). Shared step in `benchmarking/harness/northing.py`, reached by both the campaign runner and the study path (which norths per replicate, discovering for itself). `power_model`'s direction feature is on by default; all four frozen baselines -re-recorded. Two Done-when items were closed by decision rather than built, both recorded here: +re-recorded. The northing plots are wired into the shared step and written whenever it discovers +(`north_scada(out_dir=...)`); the placebo is the demonstration, since it now supplies no prior +table. Two Done-when items were closed by decision rather than built, both recorded here: * **v0's arm is dropped.** The fixture never ran `V0BinnedMethod`, so "the step bites v0" is not demonstrated. Accepted because the norther has been shown to track v0 three other ways: the From 55111d63f6b7f0a6b42365debdff157b2cae5299 Mon Sep 17 00:00:00 2001 From: aclerc Date: Fri, 4 Sep 2026 09:00:22 +0100 Subject: [PATCH 20/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- benchmarking/campaigns/methods.py | 4 +++- benchmarking/campaigns/placebo.py | 3 ++- docs/v1/issues_campaigns.md | 17 +++++++++++++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/benchmarking/campaigns/methods.py b/benchmarking/campaigns/methods.py index 6f36679a..1bc88920 100644 --- a/benchmarking/campaigns/methods.py +++ b/benchmarking/campaigns/methods.py @@ -34,7 +34,9 @@ def carried_forward_methods( :param spec: the campaign being run :param out_dir: the turbine's output folder; each method gets a subfolder named after it :param era5_hourly_df: reanalysis for the power model; omit to run it without ERA5 features - :param include_power_model: build the power model (needs the ``ml`` dependency group) + :param include_power_model: build the power model. It is the method under test, so this is + off only to avoid the ``ml`` dependency group -- a campaign without it is not testing + v1 wind-up """ methods: list[Method] = [NaiveRatioMethod(columns=HOT_COLUMNS, out_dir=out_dir / "naive_ratio", save_plots=True)] if spec.mode == "toggle": diff --git a/benchmarking/campaigns/placebo.py b/benchmarking/campaigns/placebo.py index 7bc76371..aad2ba9a 100644 --- a/benchmarking/campaigns/placebo.py +++ b/benchmarking/campaigns/placebo.py @@ -146,7 +146,8 @@ def run_placebo( :param mode: ``"prepost"`` or ``"toggle"`` :param upgraded: the test turbines; defaults to :data:`PLACEBO_UPGRADED` :param turbines: every participating turbine; defaults to :data:`PLACEBO_TURBINES` - :param include_power_model: run the power model as well as the fast methods + :param include_power_model: run the power model, the method under test. Off only for a quick + look or to avoid the ``ml`` dependency; the result is then not about v1 wind-up :param out_root: base output dir; defaults to :func:`default_output_root` :return: the campaign result, whose estimates should all read ~0 """ diff --git a/docs/v1/issues_campaigns.md b/docs/v1/issues_campaigns.md index 2ed1e1ed..05cf692e 100644 --- a/docs/v1/issues_campaigns.md +++ b/docs/v1/issues_campaigns.md @@ -34,8 +34,21 @@ and `docs/superpowers/specs/2026-08-28-v1-productization-release-design.md` ## Ground rules for this tranche -- **Methods carried forward:** `oracle`, `naive_ratio`, `power_model`, - `toggle_specialist`. **`rlearner` is dropped** (see C7). +- **What the campaigns are testing is v1 wind-up, not a field of methods.** The C-series + exists to exercise the thing being shipped, so a campaign must run it as a user would + get it. Concretely that means **`power_model` on, and northing discovered rather than + supplied** (`north_offsets=None`, the shared step doing the work). A campaign that + turns either off is testing something other than v1 wind-up, and any result from it + should be read that way. Tests may switch `power_model` off to avoid the `ml` + dependency; drivers should not. + - **In v1 wind-up:** `power_model` (definite) and the shared northing step (R1). + `toggle_specialist` is **TBD**, to be settled with evidence in W1. + - **Alongside, for comparison only:** `naive_ratio` (a deliberately simple yardstick, + never a candidate for the shipped method) and `oracle` (a sanity anchor that returns + the injected truth). + - **`rlearner` is dropped** (see C7). + The composition itself is W1's business; this rule is only about how the campaigns + must be run so their results speak about the deliverable. - **Estimand:** per-turbine uplift **plus a result representative of the upgrade using the whole farm data** (one headline campaign number, as the real HoT analyses report). - **One simulated instance per campaign, no replicates.** This tranche is about From 79edcbe50c213ded5ea84a925a58b6fc0bb18317 Mon Sep 17 00:00:00 2001 From: aclerc Date: Fri, 4 Sep 2026 09:31:39 +0100 Subject: [PATCH 21/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- benchmarking/harness/northing.py | 20 ++++++-- docs/v1/issues_campaigns.md | 15 ++++++ src/wind_up/northing.py | 38 ++++++++++++++ tests/wind_up/test_northing.py | 87 ++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 3 deletions(-) diff --git a/benchmarking/harness/northing.py b/benchmarking/harness/northing.py index 728c2215..33906f27 100644 --- a/benchmarking/harness/northing.py +++ b/benchmarking/harness/northing.py @@ -21,7 +21,14 @@ import numpy as np import pandas as pd -from wind_up.northing import DEFAULT_NORTHING, NorthingSettings, apply_north_table, north_farm, yaw_usable +from wind_up.northing import ( + DEFAULT_NORTHING, + NorthingSettings, + apply_north_table, + north_farm, + write_north_table_yaml, + yaw_usable, +) from wind_up.northing_plots import plot_northing, plot_northing_farm if TYPE_CHECKING: @@ -40,6 +47,10 @@ # farm consensus pass 2 uses is internal to north_farm. _PLOT_REFERENCE_NAME = "reanalysis" +# The discovered table, written in the format ``north_offsets`` and v0's +# ``northing_corrections_utc`` both read, so it can be hand edited and supplied back as a prior. +NORTH_TABLE_YAML = "northing_corrections.yaml" + # Open-Meteo's hub-height wind direction, the reanalysis anchor discovery is measured against. ERA5_WD_COL = "wind_direction_100m" @@ -137,8 +148,9 @@ def north_scada( discovery. Required when ``north_offsets`` is ``None``. :param roles: the direction roles to write a ``northed_`` companion for :param settings: how the changepoint search is bounded, when discovering - :param out_dir: when given and corrections are discovered, the farm overview and one plot per - device are written here, so the correction can be judged rather than trusted + :param out_dir: when given and corrections are discovered, the discovered table + (:data:`NORTH_TABLE_YAML`, hand-editable and usable as a prior), the farm overview and one + plot per device are written here, so the correction can be judged rather than trusted :return: a copy of ``scada_df`` with ``columns.northed(role)`` added for each role """ columns.require_roles(roles) @@ -189,6 +201,8 @@ def north_scada( found = sum(len(t) - 1 for t in tables.values()) logger.info("discovered %d northing changepoint(s) across %d turbines", found, len(turbines)) if out_dir is not None: + out_dir.mkdir(parents=True, exist_ok=True) + write_north_table_yaml(tables, path=out_dir / NORTH_TABLE_YAML) _write_northing_plots( index, directions=directions, usable=usable, reference=reference, tables=tables, out_dir=out_dir ) diff --git a/docs/v1/issues_campaigns.md b/docs/v1/issues_campaigns.md index 05cf692e..247f6489 100644 --- a/docs/v1/issues_campaigns.md +++ b/docs/v1/issues_campaigns.md @@ -317,6 +317,21 @@ logic, and excluded turbines. timestamps — declared from geometry in the `CampaignSpec`, not a script-level filter. - Northing-sector handling folded into the method/runner (replacing the `wd_filter` hack), so no bespoke driver code. +- **Decide what the shared northing step fits on when the upgrade itself steers the yaw.** + `WakeSteering` moves the reported nacelle position on treated rows, and `yaw_usable` + screens on power and downtime only, so those deliberately steered rows currently enter + the northing fit and the correction can absorb part of the intervention. Two things + limit the damage today and neither is a defence: the search needs a segment of at least + seven days, so rapid toggling cannot forge a changepoint, and the offsets are circular + medians, which shrug off a displaced minority. It is a level bias, unmeasured. + **The obvious fix — exclude treated rows while fitting — is wrong as a general rule**: + in prepost the treated rows are half the record, and a north step occurring inside the + campaign is exactly R1's fault, so excluding them would make it undiscoverable. So the + exclusion has to be specific to upgrades known to move the direction channel, which the + runner cannot infer from a `CampaignSpec` that deliberately carries no truth — though a + real analyst running a steering campaign would know. Settle it here: measure the bias + first, then decide whether the spec should carry "this upgrade steers yaw" or the step + should screen the rows some other way. Raised by review on PR 138. - Report + n=1 score; the farm uplift nets upstream steering losses against downstream gains. diff --git a/src/wind_up/northing.py b/src/wind_up/northing.py index b5691fa6..1228c3ab 100644 --- a/src/wind_up/northing.py +++ b/src/wind_up/northing.py @@ -27,6 +27,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Mapping + from pathlib import Path import numpy.typing as npt @@ -732,6 +733,23 @@ def _median_across(stack: npt.NDArray[np.float64], *, enough: npt.NDArray[np.boo return farm +def write_north_table_yaml(tables: Mapping[str, pd.DataFrame], *, path: Path) -> None: + """Write per-device north tables as the YAML list ``north_offsets`` and v0 both read. + + The format matches v0's ``optimized_northing_corrections.yaml``, so the file can be hand + edited and supplied back as a prior. + + :param tables: one absolute north table per device + :param path: file to write + """ + lines = [ + f" - ['{device}', {pd.Timestamp(row.timestamp).strftime('%Y-%m-%d %H:%M:%S')}, {row.north_offset}]" + for device in sorted(tables) + for row in tables[device].itertuples() + ] + path.write_text("\n".join(lines) + "\n") + + def _farm_quorum(n_devices: int, *, floor: int) -> int: """Return how many devices must report for their median to stand for the farm's consensus.""" return max(floor, n_devices // 2 + 1) @@ -797,6 +815,26 @@ def north_farm( msg = f"usable is missing masks for device(s) {missing}" raise ValueError(msg) + finite_reference = np.isfinite(np.asarray(reanalysis_deg, dtype=float)) + anchorable = {d: int((np.asarray(usable[d], dtype=bool) & finite_reference).sum()) for d in devices} + if not any(anchorable.values()): + msg = ( + "no device has a usable row where reanalysis_deg is finite, so pass 1 cannot anchor the farm. " + "Pass 2 would still return plausible relative offsets, but a farm that is uniformly wrong " + "looks self-consistent, so the result would be unanchored. Check that reanalysis_deg covers " + "index and is not all NaN." + ) + raise ValueError(msg) + thin = sorted(d for d, n in anchorable.items() if n == 0) + if len(devices) - len(thin) < min_devices_for_farm_reference: + logger.warning( + "only %d of %d devices have a usable row anchored to reanalysis (%s have none); the absolute " + "anchor rests on few devices", + len(devices) - len(thin), + len(devices), + thin, + ) + # Pass 1's reference is reanalysis, so it may only attribute large steps; pass 2's farm # consensus is clean enough for the caller's chosen threshold. anchoring = anchoring_only(settings) diff --git a/tests/wind_up/test_northing.py b/tests/wind_up/test_northing.py index f929d117..0c3db114 100644 --- a/tests/wind_up/test_northing.py +++ b/tests/wind_up/test_northing.py @@ -3,10 +3,12 @@ from __future__ import annotations from dataclasses import replace +from typing import TYPE_CHECKING import numpy as np import pandas as pd import pytest +import yaml from wind_up.circular_math import circ_diff from wind_up.northing import ( @@ -16,9 +18,13 @@ estimate_north_table, north_farm, veer_normalised, + write_north_table_yaml, yaw_usable, ) +if TYPE_CHECKING: + from pathlib import Path + TIMEBASE_S = 600 RATED_POWER = 2300.0 @@ -671,3 +677,84 @@ def test_the_reference_gives_the_same_answer_for_a_subset_of_the_farm(self) -> N assert part[name]["north_offset"].iloc[0] == pytest.approx(whole[name]["north_offset"].iloc[0], abs=2.0), ( name ) + + +class TestFarmNeedsAnAnchor: + """Pass 2 alone is blind to a farm that is uniformly wrong, so the anchor must exist.""" + + @staticmethod + def _farm(index: pd.DatetimeIndex) -> tuple[dict[str, np.ndarray], np.ndarray]: + reference = _true_direction(index) + offsets = {"A": 0.0, "B": 25.0, "C": -40.0} + reported = { + name: (reference + np.random.default_rng(10 + i).normal(0.0, 6.0, len(index)) - off) % 360.0 + for i, (name, off) in enumerate(offsets.items()) + } + return reported, reference + + def test_an_all_nan_reanalysis_raises(self) -> None: + index = _index(days=60) + reported, _ = self._farm(index) + usable = {name: _all_usable(index) for name in reported} + + with pytest.raises(ValueError, match="cannot anchor the farm"): + north_farm( + index, + direction_deg=reported, + usable=usable, + reanalysis_deg=np.full(len(index), np.nan), + settings=DEFAULT_NORTHING, + ) + + def test_a_reanalysis_that_never_overlaps_usable_rows_raises(self) -> None: + index = _index(days=60) + reported, reference = self._farm(index) + # reanalysis is finite only where no device is usable + usable = {name: _all_usable(index) for name in reported} + for mask in usable.values(): + mask[: len(index) // 2] = False + blinded = reference.copy() + blinded[len(index) // 2 :] = np.nan + + with pytest.raises(ValueError, match="cannot anchor the farm"): + north_farm(index, direction_deg=reported, usable=usable, reanalysis_deg=blinded, settings=DEFAULT_NORTHING) + + def test_a_healthy_reanalysis_still_norths(self) -> None: + index = _index(days=60) + reported, reference = self._farm(index) + usable = {name: _all_usable(index) for name in reported} + + tables = north_farm( + index, direction_deg=reported, usable=usable, reanalysis_deg=reference, settings=DEFAULT_NORTHING + ) + + assert set(tables) == set(reported) + assert float(tables["B"]["north_offset"].iloc[0]) == pytest.approx(25.0, abs=1.0) + + +class TestNorthTableYaml: + """The written table is a prior an analyst can hand edit and feed back.""" + + def test_round_trips_through_yaml(self, tmp_path: Path) -> None: + tables = { + "T02": pd.DataFrame( + { + "timestamp": pd.DatetimeIndex(["2017-01-01", "2017-06-30 12:20:00"], tz="UTC"), + "north_offset": [1.5, -33.25], + } + ), + "T01": pd.DataFrame({"timestamp": pd.DatetimeIndex(["2017-01-01"], tz="UTC"), "north_offset": [-7.125]}), + } + path = tmp_path / "northing_corrections.yaml" + + write_north_table_yaml(tables, path=path) + parsed = yaml.safe_load(path.read_text()) + + # the shape north_offsets and v0's northing_corrections_utc both read + assert [row[0] for row in parsed] == ["T01", "T02", "T02"] + assert [row[2] for row in parsed] == [-7.125, 1.5, -33.25] + assert [pd.Timestamp(row[1]).strftime("%Y-%m-%d %H:%M:%S") for row in parsed] == [ + "2017-01-01 00:00:00", + "2017-01-01 00:00:00", + "2017-06-30 12:20:00", + ] From 47da88ed29d36fdcd0473d295c68f037243c92b3 Mon Sep 17 00:00:00 2001 From: aclerc Date: Fri, 4 Sep 2026 09:54:35 +0100 Subject: [PATCH 22/26] CF9: record the placebo results with power_model after R1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- docs/v1/findings_campaigns.md | 60 +++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/docs/v1/findings_campaigns.md b/docs/v1/findings_campaigns.md index 7d23df0e..200cdb53 100644 --- a/docs/v1/findings_campaigns.md +++ b/docs/v1/findings_campaigns.md @@ -12,6 +12,66 @@ Keep entries reproducible: name the driver and the exact configuration, not just --- +## CF9 — R1 improved `power_model` on the real placebo in both modes: mean per-turbine error 0.515% → 0.312% prepost and 0.328% → 0.255% toggle, with `naive_ratio` and `toggle_specialist` unmoved to three decimals, so the gain is attributable to the direction feature and discovered northing alone + +*2026-09-04. Reproduce: `uv run python -m benchmarking.campaigns.placebo`, Hill of Towie, both +modes, defaults (six upgraded turbines T07/T11/T12/T06/T16/T19 of 21, `upgrades=[]` so truth is 0 +by construction). Compared against CF6, recorded 2026-09-02 on the same configuration before R1. +Two things changed between the runs, both of them what v1 wind-up now is: `power_model` reads each +reference's north-calibrated direction, and the placebo no longer supplies the vendored Hill of +Towie north table -- it declares `north_offsets=None` and the shared step discovers.* + +**The controls are exact, so this is a clean A/B.** `naive_ratio` reads no direction signal and +`toggle_specialist` reads none either; both reproduce CF6 to every digit recorded, on every +turbine, in both modes. Nothing but `power_model` moved. + +**Prepost, error % (truth 0):** + +| wtg | CF6 | now | Δ | +|---|---|---|---| +| T06 | +0.616 | **+0.044** | −0.572 | +| T07 | +0.462 | +0.420 | −0.042 | +| T11 | +0.188 | +0.328 | +0.140 | +| T12 | +0.337 | +0.242 | −0.095 | +| T16 | −0.721 | −0.728 | −0.007 | +| T19 | −0.769 | **−0.110** | +0.659 | +| **mean abs** | **0.515** | **0.312** | **−0.203** | +| spread | 1.385 | 1.148 | −0.237 | +| farm | +0.0390 | +0.0759 | +0.037 | + +**Toggle, error % (truth 0):** + +| wtg | CF6 | now | Δ | +|---|---|---|---| +| T06 | +0.087 | +0.007 | −0.080 | +| T07 | −0.151 | −0.119 | +0.032 | +| T11 | +0.197 | +0.242 | +0.045 | +| T12 | −0.224 | −0.139 | +0.085 | +| T16 | −0.753 | −0.694 | +0.059 | +| T19 | −0.558 | −0.329 | +0.229 | +| **mean abs** | **0.328** | **0.255** | **−0.073** | +| farm | −0.2186 | −0.1516 | −0.067 | + +**A 39% cut in prepost per-turbine error, 22% in toggle.** The prepost gain is concentrated: +T06 and T19 account for nearly all of it, and only T11 got worse. That T06 lands at +0.044% +matters beyond the average -- it is the R-series fixture turbine, chosen in CF5 for being the +most accurate and stable on site, and it is now essentially exact on a real placebo. + +**Per-turbine and farm move in opposite directions in prepost, the mirror of CF6.** CF6 found the +farm improving 4x while per-turbine accuracy slipped, and read that as better cancellation rather +than better estimates. Here the estimates genuinely improve and the farm number drifts from ++0.039% to +0.076% -- there is less residual left to cancel. Both are well inside the ±0.2% farm +target, and the per-turbine figure is the one that says the method got better. Toggle improves on +both axes. + +**Implication.** The direction feature earns its place on real data, which the frozen benchmarks +could not show: there it was neutral on the headline (−0.024 pp prepost, +0.016 pp toggle over the +`overall` cells). The benchmark measures synthetic campaigns on four turbines; the placebo is 21 +real turbines with real northing faults in the record, which is where a north-calibrated direction +has something to contribute. Worth remembering when a change reads flat on the benchmark. + +--- + ## CF8 — Veer normalisation was being defeated by its own de-stepping: measuring the sector signature around a *speculative* split removes the very veer it should describe, so the split survives. Measuring it on the normalised residual instead cut the subset study's spurious changepoints 32 → 27, left every genuine one, and ran 34% faster *2026-09-03. Reproduce: `uv run python -m benchmarking.baselines.study_northing_subsets` (99 cases, From 8f876861be04bc541b290f34472f8e1292ac3c5e Mon Sep 17 00:00:00 2001 From: aclerc Date: Fri, 4 Sep 2026 11:00:28 +0100 Subject: [PATCH 23/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- pyproject.toml | 5 +- src/wind_up/northing_plots.py | 98 ---------------------------- tests/wind_up/test_northing_plots.py | 95 +++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 100 deletions(-) create mode 100644 tests/wind_up/test_northing_plots.py diff --git a/pyproject.toml b/pyproject.toml index 55eb3ba1..160f02ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -185,6 +185,7 @@ markers = [ [tool.coverage.report] omit = [ "src/wind_up_v0/plots/*.py", + "src/wind_up/northing_plots.py", ] exclude_lines = ["if __name__ == .__main__.:"] @@ -208,14 +209,14 @@ sequence = [ [tool.poe.tasks.test-fast] help = "Runs tests that are not marked as slow" sequence = [ - { cmd = 'coverage run --source wind_up_v0 -m pytest -m "not slow"' }, + { cmd = 'coverage run --source wind_up_v0,wind_up -m pytest -m "not slow"' }, { cmd = "coverage report -m" }, ] [tool.poe.tasks.test] help = "Runs unit tests and show coverage" sequence = [ - { cmd = "coverage run --source wind_up_v0 -m pytest ./tests" }, + { cmd = "coverage run --source wind_up_v0,wind_up -m pytest ./tests" }, { cmd = "coverage report -m" }, ] diff --git a/src/wind_up/northing_plots.py b/src/wind_up/northing_plots.py index 27d64394..26385a01 100644 --- a/src/wind_up/northing_plots.py +++ b/src/wind_up/northing_plots.py @@ -192,104 +192,6 @@ def _annotate_steps(ax: plt.Axes, north_table: pd.DataFrame) -> None: ) -def circular_spread(values_deg: np.ndarray) -> float: - """Circular standard deviation (deg) of an angle sample: ``sqrt(-2 ln R)``, R the resultant.""" - finite = np.asarray(values_deg, dtype=float) - finite = finite[np.isfinite(finite)] - if len(finite) < 2: # noqa: PLR2004 - a spread needs two samples - return float("nan") - rad = np.deg2rad(finite) - resultant = np.hypot(np.mean(np.sin(rad)), np.mean(np.cos(rad))) - if resultant <= 0.0: - return float("inf") - return float(np.degrees(np.sqrt(max(-2.0 * np.log(min(resultant, 1.0)), 0.0)))) - - -def _circular_mean(values_deg: np.ndarray) -> float: - """Circular mean (deg, wrapped to +/-180) of an angle sample.""" - finite = np.asarray(values_deg, dtype=float) - finite = finite[np.isfinite(finite)] - if len(finite) == 0: - return float("nan") - rad = np.deg2rad(finite) - return float((np.degrees(np.arctan2(np.mean(np.sin(rad)), np.mean(np.cos(rad)))) + 180.0) % 360.0 - 180.0) - - -def _by_bin( - residual: np.ndarray, driver: np.ndarray, edges: np.ndarray -) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - """Circular mean, circular spread and record count of ``residual`` in each bin of ``driver``.""" - which = np.digitize(driver, edges) - 1 - centres = (edges[:-1] + edges[1:]) / 2 - mean = np.full(len(centres), np.nan) - spread = np.full(len(centres), np.nan) - count = np.zeros(len(centres)) - for b in range(len(centres)): - rows = residual[which == b] - rows = rows[np.isfinite(rows)] - count[b] = len(rows) - if len(rows) >= _MIN_ROWS_PER_POINT: - mean[b] = _circular_mean(rows) - spread[b] = circular_spread(rows) - return centres, mean, spread, count - - -def plot_residual_conditions( - residual_deg: np.ndarray, - *, - reference_deg: np.ndarray, - wind_speed: np.ndarray, - power: np.ndarray, - rated_power: float, - title: str, - sector_deg: float = _DEFAULT_SECTOR_DEG, - out_dir: Path | None = None, - filename: str = "residual_conditions.png", -) -> Figure: - """Mean and spread of the northing residual against direction, wind speed and power. - - Shows whether the residual should be weighted: a spread that blows up at low power or low - wind speed says those records count for less, a flat spread that an unweighted estimate is - fine. - - Pass the residual after northing, over the rows the estimate was allowed to use. - """ - fraction = np.asarray(power, dtype=float) / rated_power - panels = ( - ( - "wind direction [deg]", - np.asarray(reference_deg, dtype=float), - np.arange(0.0, 360.0 + sector_deg, sector_deg), - ), - ("wind speed [m/s]", np.asarray(wind_speed, dtype=float), np.arange(0.0, 26.0, 1.0)), - ("power [fraction of rated]", fraction, np.arange(0.0, 1.05, 0.05)), - ) - fig, axes = plt.subplots(1, 3, figsize=(16, 4.6)) - for ax, (label, driver, edges) in zip(axes, panels, strict=True): - centres, mean, spread, count = _by_bin(np.asarray(residual_deg, dtype=float), driver, edges) - ax.fill_between(centres, mean - spread, mean + spread, color="tab:blue", alpha=0.2, label="+/-1 circular SD") - ax.plot(centres, mean, "o-", color="tab:blue", markersize=4, label="circular mean") - ax.axhline(0.0, color="k", linewidth=0.8) - ax.axhspan(-BELIEVABLE_DEG, BELIEVABLE_DEG, color="tab:green", alpha=0.12) - ax.set_xlabel(label) - ax.set_ylabel("residual [deg]") - ax.grid(alpha=0.3) - counts = ax.twinx() - counts.bar(centres, count, width=(edges[1] - edges[0]) * 0.85, color="0.8", zorder=0, alpha=0.5) - counts.set_ylabel("records", color="0.5") - counts.tick_params(axis="y", colors="0.5") - counts.set_zorder(0) - ax.set_zorder(1) - ax.patch.set_visible(False) - axes[0].legend(fontsize="small", loc="upper left") - fig.suptitle(f"{title}: northing residual mean and spread by condition") - fig.tight_layout() - if out_dir is not None: - out_dir.mkdir(parents=True, exist_ok=True) - fig.savefig(out_dir / filename, dpi=130) - return fig - - def plot_northing_farm( index: pd.DatetimeIndex, *, diff --git a/tests/wind_up/test_northing_plots.py b/tests/wind_up/test_northing_plots.py new file mode 100644 index 00000000..340b99a4 --- /dev/null +++ b/tests/wind_up/test_northing_plots.py @@ -0,0 +1,95 @@ +"""Smoke tests for the northing plots: they must draw, save, and survive thin input.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +from wind_up.northing import estimate_north_table +from wind_up.northing_plots import plot_northing, plot_northing_farm + +if TYPE_CHECKING: + from pathlib import Path + +TIMEBASE_S = 600 + + +def _index(days: float = 400.0) -> pd.DatetimeIndex: + periods = round(days * 24 * 3600 / TIMEBASE_S) + return pd.date_range(start="2017-01-01", periods=periods, freq=f"{TIMEBASE_S}s", tz="UTC") + + +def _device(index: pd.DatetimeIndex, *, seed: int, step_deg: float) -> tuple[np.ndarray, np.ndarray]: + """Return ``(reported, reference)`` for a device that steps by ``step_deg`` halfway through.""" + rng = np.random.default_rng(seed) + reference = np.cumsum(rng.normal(0.0, 2.0, size=len(index))) % 360.0 + offset = np.where(index >= index.min() + (index.max() - index.min()) / 2, step_deg, 0.0) + reported = (reference + rng.normal(0.0, 6.0, size=len(index)) - offset) % 360.0 + return reported, reference + + +class TestPlotNorthing: + def test_draws_and_saves_a_single_device(self, tmp_path: Path) -> None: + index = _index() + reported, reference = _device(index, seed=0, step_deg=30.0) + usable = np.ones(len(index), dtype=bool) + table = estimate_north_table(index, reported, reference_deg=reference, usable=usable) + + figure = plot_northing( + index, + reported, + reference_deg=reference, + usable=usable, + north_table=table, + device="T01", + out_dir=tmp_path, + ) + plt.close(figure) + + assert (tmp_path / "T01_northing.png").is_file() + + def test_survives_a_device_with_almost_no_usable_rows(self, tmp_path: Path) -> None: + """A near-empty residual must not raise; the panels are simply blank.""" + index = _index(days=40) + reported, reference = _device(index, seed=1, step_deg=0.0) + usable = np.zeros(len(index), dtype=bool) + usable[:5] = True + table = estimate_north_table(index, reported, reference_deg=reference, usable=usable) + + figure = plot_northing( + index, reported, reference_deg=reference, usable=usable, north_table=table, device="T02", out_dir=tmp_path + ) + plt.close(figure) + + assert (tmp_path / "T02_northing.png").is_file() + + +class TestPlotNorthingFarm: + def test_draws_one_panel_per_device_and_saves(self, tmp_path: Path) -> None: + index = _index() + names = ("T01", "T02", "T03", "T04") + reported, reference = {}, None + for i, name in enumerate(names): + reported[name], reference = _device(index, seed=i, step_deg=10.0 * i) + usable = {name: np.ones(len(index), dtype=bool) for name in names} + tables = { + name: estimate_north_table(index, reported[name], reference_deg=reference, usable=usable[name]) + for name in names + } + + figure = plot_northing_farm( + index, + direction_deg=reported, + reference_deg=reference, + usable=usable, + north_tables=tables, + out_dir=tmp_path, + ) + visible = [ax for ax in figure.axes if ax.get_visible()] + plt.close(figure) + + assert (tmp_path / "farm_northing.png").is_file() + assert len(visible) == len(names) From dcf78ee01e48c1062c8e120faf3c7fbc8d5eeea3 Mon Sep 17 00:00:00 2001 From: aclerc Date: Fri, 4 Sep 2026 11:00:55 +0100 Subject: [PATCH 24/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- docs/v1/findings_campaigns.md | 15 +++++++ src/wind_up/northing.py | 80 +++++++++++++++++----------------- tests/wind_up/test_northing.py | 29 +++++++++++- 3 files changed, 83 insertions(+), 41 deletions(-) diff --git a/docs/v1/findings_campaigns.md b/docs/v1/findings_campaigns.md index 200cdb53..0356b6a7 100644 --- a/docs/v1/findings_campaigns.md +++ b/docs/v1/findings_campaigns.md @@ -12,6 +12,21 @@ Keep entries reproducible: name the driver and the exact configuration, not just --- +## CF10 — A low-effort `NorthingSettings` tier was measured and dropped: the changepoint search is a small part of the runtime (a whole farm-year differs by ~2 seconds) and a smaller changepoint budget cost real detections, so there is one setting rather than a menu + +*2026-09-04. Recorded when the justification was removed from the `NorthingSettings` docstring +under the `src/` "behaviour, not justification" rule; the measurement itself was made during R1.* + +**Observed.** A reduced tier (smaller `changepoints_per_year`, coarser `grid`) was built and run +against the same cases as the default. It saved ~2 seconds on a whole farm-year — the search is +not where the runtime goes — and it lost genuine detections, because the changepoint budget is +what lets a long record hold every recalibration it actually contains. + +**Decision.** `NorthingSettings` ships as a single default, `DEFAULT_NORTHING`. Construct one +only to tune deliberately; there is no tier to choose between. The two derived settings that do +exist — `anchoring_only` and `against_reanalysis` — are not tiers: each raises `min_step_deg` +for a reference that cannot support finer attribution. + ## CF9 — R1 improved `power_model` on the real placebo in both modes: mean per-turbine error 0.515% → 0.312% prepost and 0.328% → 0.255% toggle, with `naive_ratio` and `toggle_specialist` unmoved to three decimals, so the gain is attributable to the direction feature and discovered northing alone *2026-09-04. Reproduce: `uv run python -m benchmarking.campaigns.placebo`, Hill of Towie, both diff --git a/src/wind_up/northing.py b/src/wind_up/northing.py index 1228c3ab..5d6a70b5 100644 --- a/src/wind_up/northing.py +++ b/src/wind_up/northing.py @@ -61,10 +61,6 @@ class NorthingSettings: """How the changepoint search is bounded, in physical units. - There is one setting, not a menu: a low-effort tier was measured and dropped, because the - search is a small part of the runtime (a whole farm-year differs by ~2 seconds) and a smaller - changepoint budget cost real detections. Construct one of these only to tune deliberately. - :param changepoints_per_year: budget of changepoints per year of record, so a longer record is allowed more; the cap is ``max(min_changepoints, ceil(rate * years))`` :param min_changepoints: floor on that budget, so a short record can still hold several @@ -161,6 +157,7 @@ def _table(timestamps: list[pd.Timestamp], offsets: list[float]) -> pd.DataFrame def _residual( direction_deg: npt.NDArray[np.float64], + *, reference_deg: npt.NDArray[np.float64], usable: npt.NDArray[np.bool_], ) -> npt.NDArray[np.float64]: @@ -220,20 +217,14 @@ def veer_normalised( ) -> npt.NDArray[np.float64]: """Remove each direction sector's own long-run level from the residual. - Across a site the wind direction differs from turbine to turbine -- veer, varying with the - bulk direction, stability and wind speed. A turbine's residual therefore has a level that - depends on *which* directions the wind blew from, so a shift in the direction mix moves the - level without anything at the turbine changing, and a changepoint search reads that as a step. - - Subtracting each sector's whole-record median removes it: a genuine north offset shifts every - sector alike and so survives. Sectors with too little data fall back to the overall level. + Subtracting each sector's whole-record median leaves a genuine north offset intact, since one + shifts every sector alike. Sectors with too little data fall back to the overall level. Use this for detection only -- segment offsets are estimated from the raw residual, so the correction stays absolute. :param de_stepped: the residual with a first-pass estimate of the step structure removed. The - sector levels are measured on it rather than on ``residual``, so a large step cannot leak - into the veer signature. Defaults to ``residual`` itself. + sector levels are measured on it rather than on ``residual``. Defaults to ``residual``. """ signature = _sector_signature( residual if de_stepped is None else de_stepped, @@ -264,8 +255,9 @@ def _sector_signature( if not finite.any(): return np.full(len(values_deg), np.nan) n_sectors = max(1, int(np.ceil(360.0 / sector_deg))) + has_reference = np.isfinite(reference_deg) sector = np.zeros(len(values_deg), dtype=int) - sector[finite] = (np.mod(reference_deg[finite], 360.0) // sector_deg).astype(int) % n_sectors + sector[has_reference] = (np.mod(reference_deg[has_reference], 360.0) // sector_deg).astype(int) % n_sectors overall = float(circ_median(values_deg[finite], range_360=False)) level = np.full(n_sectors, overall) @@ -274,7 +266,7 @@ def _sector_signature( if int(rows.sum()) >= min_rows_per_sector: level[s] = float(circ_median(values_deg[rows], range_360=False)) out = np.full(len(values_deg), np.nan) - out[np.isfinite(reference_deg)] = level[sector[np.isfinite(reference_deg)]] + out[has_reference] = level[sector[has_reference]] return out @@ -527,9 +519,8 @@ def _worst_unsupported( ) -> int | None: """Return the changepoint whose step falls furthest short of what its record can support. - This is what makes ``min_step_deg`` mean what it says: a step smaller than it is never - reported. Near the start or end of a record -- or squeezed between two other changepoints -- - more is required, because there is less data with which to tell a step from veer. + A step smaller than ``min_step_deg`` is never reported. Near the start or end of a record -- + or squeezed between two other changepoints -- more is required. """ required = _required_step( changepoints, @@ -586,8 +577,7 @@ def estimate_north_table( :param usable: rows whose comparison is meaningful -- see :func:`yaw_usable`. Also the place to exclude periods when the direction is deliberately offset, such as a turbine steering its wake. - :param settings: how the search is bounded; the default suits a farm record and there is no - tier to choose between + :param settings: how the search is bounded; the default suits a farm record :return: columns ``timestamp`` and ``north_offset``, one row per period, the first row at the start of ``index``. Always at least one row; all-zero when nothing is usable. """ @@ -609,7 +599,7 @@ def estimate_north_table( order = np.argsort(index.to_numpy()) index, direction, reference, ok = index[order], direction[order], reference[order], ok[order] - residual = _residual(direction, reference, ok) + residual = _residual(direction, reference_deg=reference, usable=ok) start = index.min() if not np.isfinite(residual).any(): logger.warning("no usable rows to north against; returning a zero offset") @@ -652,12 +642,13 @@ def detect(searched: npt.NDArray[np.float64]) -> list[pd.Timestamp]: if settings.veer_sector_deg is None: changepoints = detect(residual) else: + sector_deg = settings.veer_sector_deg def normalised(de_stepped: npt.NDArray[np.float64] | None) -> npt.NDArray[np.float64]: return veer_normalised( residual, reference_deg=reference, - sector_deg=settings.veer_sector_deg, # type: ignore[arg-type] + sector_deg=sector_deg, de_stepped=de_stepped, ) @@ -673,21 +664,36 @@ def normalised(de_stepped: npt.NDArray[np.float64] | None) -> npt.NDArray[np.flo ) offsets = _segment_offsets(changepoints, start=start, residual=residual, index=index) - bounds = {"start": start, "residual": residual, "index": index} - rule = { - "start": start, - "end": end, - "min_step_deg": settings.min_step_deg, - "max_transient_step_deg": settings.max_transient_step_deg, - } # First iron out excursions, then drop what the record cannot support. Order matters: a step # only looks unsupported once the excursion around it has gone. - changepoints, offsets = _prune_while(changepoints, offsets, **bounds, worst=partial(_worst_transient, **rule)) changepoints, offsets = _prune_while( changepoints, offsets, - **bounds, - worst=partial(_worst_unsupported, **rule, confident_segment=settings.confident_segment), + start=start, + residual=residual, + index=index, + worst=partial( + _worst_transient, + start=start, + end=end, + min_step_deg=settings.min_step_deg, + max_transient_step_deg=settings.max_transient_step_deg, + ), + ) + changepoints, offsets = _prune_while( + changepoints, + offsets, + start=start, + residual=residual, + index=index, + worst=partial( + _worst_unsupported, + start=start, + end=end, + min_step_deg=settings.min_step_deg, + max_transient_step_deg=settings.max_transient_step_deg, + confident_segment=settings.confident_segment, + ), ) return _table([start, *changepoints], offsets) @@ -763,10 +769,7 @@ def _farm_direction( ) -> npt.NDArray[np.float64]: """Per-timestamp circular median of the devices' northed directions, NaN where too few report. - ``min_devices`` is what keeps this trustworthy. Devices differ from the consensus by their own - direction-dependent veer, so a median over only a few of them is not the farm's consensus -- - and when an outage coincides with an unusual wind direction, every device appears to step at - once and back again. The guard is a quorum rather than a floor: see :func:`north_farm`. + ``min_devices`` is a quorum, not a fixed floor: see :func:`north_farm`. """ stack = np.vstack( [np.where(usable[name] & np.isfinite(values), values, np.nan) for name, values in northed.items()] @@ -798,10 +801,7 @@ def north_farm( :param reanalysis_deg: the absolute direction reference, on ``index`` :param min_devices_for_farm_reference: the floor on how many devices must report at a timestamp for the consensus to be defined there, and the minimum farm size. The effective - requirement is the larger of this and a strict majority of the farm: a median over an - unrepresentative few - carries their veer rather than the farm's, which is what makes an outage look like every - turbine stepping at once. + requirement is the larger of this and a strict majority of the farm. """ devices = sorted(direction_deg) if len(devices) < min_devices_for_farm_reference: diff --git a/tests/wind_up/test_northing.py b/tests/wind_up/test_northing.py index 0c3db114..558b46f5 100644 --- a/tests/wind_up/test_northing.py +++ b/tests/wind_up/test_northing.py @@ -10,10 +10,11 @@ import pytest import yaml -from wind_up.circular_math import circ_diff +from wind_up.circular_math import circ_diff, circ_median from wind_up.northing import ( DEFAULT_NORTHING, NorthingSettings, + _sector_signature, apply_north_table, estimate_north_table, north_farm, @@ -758,3 +759,29 @@ def test_round_trips_through_yaml(self, tmp_path: Path) -> None: "2017-01-01 00:00:00", "2017-06-30 12:20:00", ] + + +class TestSectorSignature: + """The level reported for a row must come from that row's own direction sector.""" + + def test_a_row_with_no_value_still_reads_its_own_sector(self) -> None: + # sector 0 sits at +5 and sector 4 at -8; the rows pointing at 300 deg (sector 10) carry + # no value of their own, so they must fall back to the overall level, not to sector 0's + reference = np.array([10.0] * 100 + [130.0] * 100 + [300.0] * 100) + values = np.array([5.0] * 100 + [-8.0] * 100 + [np.nan] * 100) + + signature = _sector_signature(values, reference_deg=reference, sector_deg=30.0, min_rows_per_sector=50) + + overall = float(circ_median(values[np.isfinite(values)], range_360=False)) + assert signature[0] == pytest.approx(5.0) + assert signature[150] == pytest.approx(-8.0) + assert signature[250] == pytest.approx(overall), "a NaN-valued row took sector 0's level by accident" + + def test_each_populated_sector_gets_its_own_level(self) -> None: + reference = np.array([10.0] * 100 + [300.0] * 100) + values = np.array([5.0] * 100 + [-8.0] * 100) + + signature = _sector_signature(values, reference_deg=reference, sector_deg=30.0, min_rows_per_sector=50) + + assert signature[0] == pytest.approx(5.0) + assert signature[150] == pytest.approx(-8.0) From 56eb6677227c32365a0c082b8373ddd433da2571 Mon Sep 17 00:00:00 2001 From: aclerc Date: Fri, 4 Sep 2026 11:01:42 +0100 Subject: [PATCH 25/26] 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 Claude-Session: https://claude.ai/code/session_01ECPVDa4P3dbiWYCQKbz18D --- src/wind_up_v0/optimize_northing.py | 27 ++++++++++++------------ tests/wind_up/test_northing_real_data.py | 5 +++-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/wind_up_v0/optimize_northing.py b/src/wind_up_v0/optimize_northing.py index e8e11ab6..e79926f8 100644 --- a/src/wind_up_v0/optimize_northing.py +++ b/src/wind_up_v0/optimize_northing.py @@ -19,14 +19,15 @@ from wind_up.northing import ( DEFAULT_NORTHING, - NORTH_OFFSET_COL, NorthingSettings, against_reanalysis, anchoring_only, apply_north_table, estimate_north_table, + write_north_table_yaml, yaw_usable, ) +from wind_up.northing import TIMESTAMP_COL as NORTHING_TIMESTAMP_COL from wind_up_v0.circular_math import circ_diff, rolling_circ_median_approx from wind_up_v0.constants import ( RAW_DOWNTIME_S_COL, @@ -67,9 +68,7 @@ def _farm_reference_is_independent(wf_df: pd.DataFrame) -> bool: """Whether the wind-farm yaw direction is genuinely farm-derived rather than reanalysis. - A farm consensus shares the site's common-mode direction error, which is what makes small - steps attributable to a single turbine. Where it has fallen back to reanalysis it carries no - such information, and the second pass must stay as conservative as the first. + Where it has fallen back to reanalysis the second pass stays as conservative as the first. """ if WINDFARM_YAWDIR_COL not in wf_df.columns: return False @@ -222,14 +221,13 @@ def _north_wf_table( def _write_northing_yaml(wf_north_table: pd.DataFrame, *, fpath: Path) -> None: """Write a wind-farm north table as the YAML list ``northing_corrections_utc`` expects.""" - north_table_for_yaml = wf_north_table.copy() - north_table_for_yaml[TIMESTAMP_COL] = north_table_for_yaml[TIMESTAMP_COL].dt.strftime("%Y-%m-%d %H:%M:%S") - yaml_strings = [ - f" - ['{row['TurbineName']}', {row[TIMESTAMP_COL]}, {row[NORTH_OFFSET_COL]}]" - for _, row in north_table_for_yaml.iterrows() - ] - with fpath.open(mode="w") as yaml_file: - yaml_file.write("\n".join(yaml_strings)) + write_north_table_yaml( + { + str(name): rows.rename(columns={TIMESTAMP_COL: NORTHING_TIMESTAMP_COL}) + for name, rows in wf_north_table.groupby("TurbineName", observed=True) + }, + path=fpath, + ) def auto_northing_corrections( @@ -269,9 +267,10 @@ def auto_northing_corrections( if plot_cfg is not None: plot_wf_yawdir_and_reanalysis_timeseries(wf_df, cfg=cfg, plot_cfg=plot_cfg) - farm_settings = settings if _farm_reference_is_independent(wf_df) else against_reanalysis(settings) - if farm_settings is not settings: + farm_reference_is_independent = _farm_reference_is_independent(wf_df) + if not farm_reference_is_independent: logger.info("wind farm yaw direction fell back to reanalysis; northing conservatively") + farm_settings = settings if farm_reference_is_independent else against_reanalysis(settings) optimized_northing_corrections = _north_wf_table( wf_df, north_ref_wd_col=WINDFARM_YAWDIR_COL, cfg=cfg, plot_cfg=plot_cfg, settings=farm_settings ) diff --git a/tests/wind_up/test_northing_real_data.py b/tests/wind_up/test_northing_real_data.py index 6b6a6acf..a1d25187 100644 --- a/tests/wind_up/test_northing_real_data.py +++ b/tests/wind_up/test_northing_real_data.py @@ -177,10 +177,11 @@ def test_no_turbine_steps_during_a_farm_outage(self, hot: pd.DataFrame) -> None: assert offenders == {}, f"turbines stepped with the outage, not their own calibration: {offenders}" def test_the_outage_years_are_quiet(self, hot: pd.DataFrame) -> None: - """Run 2019-2020 on its own: four changepoints across 21 turbines, all in v0's table.""" + """Run 2019-2020 on its own: every changepoint across 21 turbines is in v0's table.""" found = run_farm(hot, ALL_TURBINES, *LATE) + expected = sum(len(v) for v in EXPECTED[LATE].values()) total = sum(len(v) for v in found.values()) - assert total == 4, {n: _describe(v) for n, v in found.items() if v} + assert total == expected, {n: _describe(v) for n, v in found.items() if v} class TestEdgeArtefacts: From 71150874cd6437f0bc47b51d25ffcbdd29370f0c Mon Sep 17 00:00:00 2001 From: aclerc Date: Fri, 4 Sep 2026 11:18:59 +0100 Subject: [PATCH 26/26] tidy docstring --- src/wind_up_v0/optimize_northing.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/wind_up_v0/optimize_northing.py b/src/wind_up_v0/optimize_northing.py index e79926f8..bf3ea9ee 100644 --- a/src/wind_up_v0/optimize_northing.py +++ b/src/wind_up_v0/optimize_northing.py @@ -4,9 +4,6 @@ MultiIndex wind-farm frame, a :class:`~wind_up_v0.models.WindUpConfig`, the ``raw_`` column names) and its reporting -- logging, plots and the corrections YAML -- while the estimation itself is the shared v1 core. - -The two-pass structure is unchanged: north every turbine to reanalysis wind direction, derive -the wind-farm yaw direction from the result, then north every turbine to that. """ from __future__ import annotations