eb-optimization 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ `eb_optimization` — optimization and tuning layer for the Electric Barometer ecosystem.
5
+
6
+ This package contains the **optimization layer** of Electric Barometer:
7
+
8
+ - **search**: generic, reusable search mechanics (grids, tie-breaking kernels)
9
+ - **tuning**: calibration and selection utilities (e.g., cost-ratio tuning, sensitivity sweeps)
10
+ - **policies**: frozen, declarative policy artifacts for downstream execution
11
+
12
+ It intentionally does **not** define metric primitives or evaluation math.
13
+ Those live in `eb-metrics` (and orchestration lives in `eb-evaluation`).
14
+ """
15
+
16
+ from importlib.metadata import PackageNotFoundError, version
17
+
18
+
19
+ def _resolve_version() -> str:
20
+ """
21
+ Resolve the installed distribution version.
22
+
23
+ Returns
24
+ -------
25
+ str
26
+ Installed version string. If the distribution is not installed (e.g., running
27
+ from source), returns ``"0.0.0"``.
28
+ """
29
+ try:
30
+ # Must match the distribution name in pyproject.toml ([project].name)
31
+ return version("eb-optimization")
32
+ except PackageNotFoundError:
33
+ return "0.0.0"
34
+
35
+
36
+ __version__ = _resolve_version()
37
+
38
+ __all__ = ["__version__"]
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ from numpy.typing import ArrayLike
5
+
6
+ __all__ = [
7
+ "to_1d_array",
8
+ "broadcast_param",
9
+ "handle_sample_weight",
10
+ ]
11
+
12
+
13
+ def to_1d_array(x: ArrayLike, name: str) -> np.ndarray:
14
+ """
15
+ Convert input to a 1D numpy float array.
16
+
17
+ Parameters
18
+ ----------
19
+ x
20
+ Array-like input.
21
+ name
22
+ Name used in error messages.
23
+
24
+ Returns
25
+ -------
26
+ numpy.ndarray
27
+ 1D float array.
28
+
29
+ Raises
30
+ ------
31
+ ValueError
32
+ If the input is not 1-dimensional.
33
+ """
34
+ arr = np.asarray(x, dtype=float)
35
+
36
+ if arr.ndim != 1:
37
+ raise ValueError(f"{name} must be a 1D array; got shape {arr.shape}")
38
+
39
+ return arr
40
+
41
+
42
+ def broadcast_param(x: ArrayLike, shape: tuple[int, ...], name: str) -> np.ndarray:
43
+ """
44
+ Broadcast a scalar or 1D array parameter to a target shape.
45
+
46
+ Rules
47
+ -----
48
+ - Scalars are expanded to the given shape
49
+ - 1D arrays must exactly match the target shape
50
+
51
+ Parameters
52
+ ----------
53
+ x
54
+ Scalar or 1D array parameter.
55
+ shape
56
+ Target shape.
57
+ name
58
+ Name used in error messages.
59
+
60
+ Returns
61
+ -------
62
+ numpy.ndarray
63
+ Float array of shape ``shape``.
64
+
65
+ Raises
66
+ ------
67
+ ValueError
68
+ If ``x`` is neither scalar nor matches the target shape.
69
+ """
70
+ arr = np.asarray(x, dtype=float)
71
+
72
+ if arr.ndim == 0:
73
+ return np.full(shape, float(arr), dtype=float)
74
+
75
+ if arr.shape != shape:
76
+ raise ValueError(
77
+ f"{name} must be scalar or have shape {shape}; got shape {arr.shape}"
78
+ )
79
+
80
+ return arr
81
+
82
+
83
+ def handle_sample_weight(sample_weight: ArrayLike | None, n: int) -> np.ndarray:
84
+ """
85
+ Normalize sample weights to a non-negative 1D float array of length n.
86
+
87
+ If ``sample_weight`` is None, returns an array of ones.
88
+
89
+ Parameters
90
+ ----------
91
+ sample_weight
92
+ None or a 1D array of non-negative weights.
93
+ n
94
+ Expected length.
95
+
96
+ Returns
97
+ -------
98
+ numpy.ndarray
99
+ 1D float array of length n.
100
+
101
+ Raises
102
+ ------
103
+ ValueError
104
+ If weights are not length-n, not 1D, or contain negative values.
105
+ """
106
+ if n <= 0:
107
+ raise ValueError(f"n must be a positive integer; got {n}")
108
+
109
+ if sample_weight is None:
110
+ return np.ones(n, dtype=float)
111
+
112
+ w = np.asarray(sample_weight, dtype=float)
113
+
114
+ if w.ndim != 1 or w.shape[0] != n:
115
+ raise ValueError(
116
+ f"sample_weight must be a 1D array of length {n}; got shape {w.shape}"
117
+ )
118
+
119
+ if np.any(w < 0):
120
+ raise ValueError("sample_weight must be non-negative.")
121
+
122
+ return w
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Frozen policy artifacts for the Electric Barometer optimization layer.
5
+
6
+ The `eb_optimization.policies` package contains **governance-level, immutable
7
+ configuration objects** that define how tuned parameters are selected and applied
8
+ at runtime.
9
+
10
+ Design principles
11
+ -----------------
12
+ - Policies are **frozen** (dataclass(frozen=True)) and versionable
13
+ - Policies contain **no learning or tuning logic**
14
+ - Policies wrap tuning utilities with deterministic application semantics
15
+ - Policies are safe to ship to production systems
16
+
17
+ Layering
18
+ --------
19
+ - tuning/ : derives parameters from data (calibration, grid search)
20
+ - policies/ : freezes configuration + applies tuning deterministically
21
+ - runtime : consumes policy outputs only (no re-tuning)
22
+
23
+ Exported policies
24
+ -----------------
25
+ - Tau (τ) tolerance governance for HR@τ
26
+ - Cost-ratio (R = c_u / c_o) governance for asymmetric loss
27
+ - RAL policy governance (readiness adjustment layer)
28
+ """
29
+
30
+ # ---------------------------------------------------------------------
31
+ # Tau (tolerance) policies
32
+ # ---------------------------------------------------------------------
33
+ from .tau_policy import (
34
+ TauPolicy,
35
+ apply_tau_policy,
36
+ apply_tau_policy_hr,
37
+ apply_entity_tau_policy,
38
+ )
39
+
40
+ # ---------------------------------------------------------------------
41
+ # Cost-ratio (R) policies
42
+ # ---------------------------------------------------------------------
43
+ from .cost_ratio_policy import (
44
+ CostRatioPolicy,
45
+ DEFAULT_COST_RATIO_POLICY,
46
+ apply_cost_ratio_policy,
47
+ apply_entity_cost_ratio_policy,
48
+ )
49
+
50
+ # ---------------------------------------------------------------------
51
+ # RAL policies
52
+ # ---------------------------------------------------------------------
53
+ from .ral_policy import (
54
+ RALPolicy,
55
+ DEFAULT_RAL_POLICY,
56
+ apply_ral_policy,
57
+ )
58
+
59
+ __all__ = [
60
+ # Tau policies
61
+ "TauPolicy",
62
+ "apply_tau_policy",
63
+ "apply_tau_policy_hr",
64
+ "apply_entity_tau_policy",
65
+ # Cost ratio policies
66
+ "CostRatioPolicy",
67
+ "DEFAULT_COST_RATIO_POLICY",
68
+ "apply_cost_ratio_policy",
69
+ "apply_entity_cost_ratio_policy",
70
+ # RAL policies
71
+ "RALPolicy",
72
+ "DEFAULT_RAL_POLICY",
73
+ "apply_ral_policy",
74
+ ]
@@ -0,0 +1,294 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Cost-ratio (R = c_u / c_o) policy artifacts for eb-optimization.
5
+
6
+ This module defines *frozen governance* for selecting and applying a cost ratio `R`
7
+ (and derived underbuild cost `c_u`) used by asymmetric cost metrics like CWSL.
8
+
9
+ Layering & responsibilities
10
+ ---------------------------
11
+ - `tuning/cost_ratio.py`:
12
+ Calibration logic (estimating R from residuals / cost balance).
13
+ - `policies/cost_ratio_policy.py`:
14
+ Frozen configuration + deterministic application wrappers.
15
+
16
+ The policy layer exists so downstream consumers can:
17
+ - pin method + governance settings in a versioned, auditable object
18
+ - apply the same selection logic consistently across environments
19
+ - avoid re-encoding configuration details in orchestration code
20
+
21
+ Design intent
22
+ -------------
23
+ Policies should be:
24
+ - stable: schema changes are deliberate and versioned
25
+ - deterministic: same inputs + same policy -> same outputs
26
+ - safe: validated governance parameters + clear failure semantics
27
+
28
+ This module provides:
29
+ - `CostRatioPolicy`: frozen configuration
30
+ - `DEFAULT_COST_RATIO_POLICY`: exported default policy
31
+ - `apply_cost_ratio_policy`: estimate global R from arrays/Series
32
+ - `apply_entity_cost_ratio_policy`: estimate per-entity R from a DataFrame
33
+
34
+ Notes
35
+ -----
36
+ - This policy does *not* compute CWSL. It only governs parameter selection.
37
+ - `co` may be scalar or per-interval array for global estimation; for entity-level
38
+ estimation, `co` is currently modeled as a scalar (consistent with tuning).
39
+ """
40
+
41
+ from dataclasses import dataclass
42
+ from typing import Any, Dict, Mapping, Optional, Sequence, Tuple, Union
43
+
44
+ import numpy as np
45
+ import pandas as pd
46
+ from numpy.typing import ArrayLike
47
+
48
+ from eb_optimization.tuning.cost_ratio import (
49
+ estimate_R_cost_balance,
50
+ estimate_entity_R_from_balance,
51
+ )
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class CostRatioPolicy:
56
+ """
57
+ Frozen cost-ratio (R) policy configuration.
58
+
59
+ Attributes
60
+ ----------
61
+ R_grid : Sequence[float]
62
+ Candidate ratios to search. Only strictly positive values are considered.
63
+ Order matters for tie-breaking (first-minimizer behavior).
64
+ co : float
65
+ Default overbuild cost coefficient used for entity-level estimation
66
+ (and as a default for global estimation if `co` is not passed at call time).
67
+ min_n : int
68
+ Minimum number of observations required to estimate an entity-level R.
69
+ Entities with fewer observations are returned with NaN R (and diagnostics).
70
+ """
71
+
72
+ R_grid: Sequence[float] = (0.5, 1.0, 2.0, 3.0)
73
+ co: float = 1.0
74
+ min_n: int = 30
75
+
76
+ def __post_init__(self) -> None:
77
+ grid = np.asarray(list(self.R_grid), dtype=float)
78
+ if grid.ndim != 1 or grid.size == 0:
79
+ raise ValueError("R_grid must be a non-empty 1D sequence of floats.")
80
+ if not np.any(grid > 0):
81
+ raise ValueError("R_grid must contain at least one strictly positive value.")
82
+
83
+ if not np.isfinite(self.co) or float(self.co) <= 0:
84
+ raise ValueError(f"co must be finite and strictly positive. Got {self.co}.")
85
+
86
+ if self.min_n < 1:
87
+ raise ValueError(f"min_n must be >= 1. Got {self.min_n}.")
88
+
89
+
90
+ DEFAULT_COST_RATIO_POLICY = CostRatioPolicy()
91
+
92
+
93
+ def apply_cost_ratio_policy(
94
+ y_true: ArrayLike,
95
+ y_pred: ArrayLike,
96
+ *,
97
+ policy: CostRatioPolicy = DEFAULT_COST_RATIO_POLICY,
98
+ co: Union[float, ArrayLike, None] = None,
99
+ sample_weight: ArrayLike | None = None,
100
+ ) -> Tuple[float, Dict[str, Any]]:
101
+ """
102
+ Apply a frozen cost-ratio policy to estimate a global R.
103
+
104
+ Parameters
105
+ ----------
106
+ y_true, y_pred
107
+ Realized and forecast demand vectors (non-negative, same shape).
108
+ policy
109
+ Frozen configuration controlling the candidate grid and defaults.
110
+ co
111
+ Overbuild cost coefficient. If None, uses `policy.co`.
112
+ Can be scalar or per-interval array (passed through to tuning).
113
+ sample_weight
114
+ Optional non-negative per-interval weights.
115
+
116
+ Returns
117
+ -------
118
+ (R, diagnostics)
119
+ R is a float.
120
+ Diagnostics include the chosen R, co summary, and candidate grid.
121
+
122
+ Notes
123
+ -----
124
+ The underlying selection rule is in `tuning.estimate_R_cost_balance`:
125
+ choose R that minimizes |under_cost(R) - over_cost|.
126
+ """
127
+ co_val = policy.co if co is None else co
128
+
129
+ R = float(
130
+ estimate_R_cost_balance(
131
+ y_true=y_true,
132
+ y_pred=y_pred,
133
+ R_grid=policy.R_grid,
134
+ co=co_val,
135
+ sample_weight=sample_weight,
136
+ )
137
+ )
138
+
139
+ diag: Dict[str, Any] = {
140
+ "method": "cost_balance",
141
+ "R_grid": list(map(float, policy.R_grid)),
142
+ "co_is_array": isinstance(co_val, (list, tuple, np.ndarray, pd.Series)),
143
+ "co_default_used": co is None,
144
+ "R": R,
145
+ }
146
+ return (R, diag)
147
+
148
+
149
+ def apply_entity_cost_ratio_policy(
150
+ df: pd.DataFrame,
151
+ *,
152
+ entity_col: str,
153
+ y_true_col: str,
154
+ y_pred_col: str,
155
+ policy: CostRatioPolicy = DEFAULT_COST_RATIO_POLICY,
156
+ co: Optional[float] = None,
157
+ sample_weight_col: Optional[str] = None,
158
+ include_diagnostics: bool = True,
159
+ ) -> pd.DataFrame:
160
+ """
161
+ Apply a frozen cost-ratio policy per entity.
162
+
163
+ This wraps `tuning.estimate_entity_R_from_balance` but adds policy governance:
164
+ - enforces a minimum sample size per entity (min_n)
165
+ - pins the candidate ratio grid (R_grid)
166
+ - provides stable, auditable defaults for co and configuration
167
+
168
+ Parameters
169
+ ----------
170
+ df
171
+ Input data containing entity ids, actuals, forecasts, and optionally weights.
172
+ entity_col
173
+ Column name identifying the entity to calibrate (e.g., restaurant_id).
174
+ y_true_col, y_pred_col
175
+ Column names for realized demand and forecast.
176
+ policy
177
+ Frozen configuration controlling ratios grid and governance.
178
+ co
179
+ Scalar overbuild cost coefficient. If None, uses `policy.co`.
180
+ sample_weight_col
181
+ Optional column name containing non-negative sample weights.
182
+ include_diagnostics
183
+ If True, returns diagnostics columns (under_cost, over_cost, diff) produced
184
+ by tuning. If False, trims to [entity_col, R, cu, co] plus governance columns.
185
+
186
+ Returns
187
+ -------
188
+ pandas.DataFrame
189
+ One row per entity with chosen R and supporting diagnostics. Entities
190
+ with fewer than `policy.min_n` observations will be returned with NaN R.
191
+
192
+ Raises
193
+ ------
194
+ KeyError
195
+ If required columns are missing.
196
+ ValueError
197
+ If invalid governance values or negative weights are present.
198
+
199
+ Notes
200
+ -----
201
+ The tuning implementation currently treats `co` as a scalar for entity-level
202
+ estimation (consistent with the function signature in tuning).
203
+ """
204
+ # ---- validation: columns ----
205
+ if entity_col not in df.columns:
206
+ raise KeyError(f"entity_col {entity_col!r} not found in df")
207
+ if y_true_col not in df.columns:
208
+ raise KeyError(f"y_true_col {y_true_col!r} not found in df")
209
+ if y_pred_col not in df.columns:
210
+ raise KeyError(f"y_pred_col {y_pred_col!r} not found in df")
211
+ if sample_weight_col is not None and sample_weight_col not in df.columns:
212
+ raise KeyError(f"sample_weight_col {sample_weight_col!r} not found in df")
213
+
214
+ co_val = float(policy.co if co is None else co)
215
+ if not np.isfinite(co_val) or co_val <= 0:
216
+ raise ValueError(f"co must be finite and strictly positive. Got {co_val}.")
217
+
218
+ # ---- governance: min_n (simple deterministic rule: row count per entity) ----
219
+ counts = df.groupby(entity_col, dropna=False, sort=False).size()
220
+ eligible_entities = counts[counts >= policy.min_n].index
221
+ ineligible_entities = counts[counts < policy.min_n].index
222
+
223
+ eligible = df[df[entity_col].isin(eligible_entities)].copy()
224
+ ineligible = df[df[entity_col].isin(ineligible_entities)].copy()
225
+
226
+ # ---- tune eligible entities ----
227
+ if not eligible.empty:
228
+ tuned = estimate_entity_R_from_balance(
229
+ df=eligible,
230
+ entity_col=entity_col,
231
+ y_true_col=y_true_col,
232
+ y_pred_col=y_pred_col,
233
+ ratios=policy.R_grid,
234
+ co=co_val,
235
+ sample_weight_col=sample_weight_col,
236
+ ).copy()
237
+ tuned["reason"] = None
238
+ tuned["n"] = tuned[entity_col].map(counts).astype(int)
239
+ else:
240
+ tuned = pd.DataFrame(
241
+ columns=[entity_col, "R", "cu", "co", "under_cost", "over_cost", "diff", "reason", "n"]
242
+ )
243
+
244
+ # ---- build rows for ineligible entities (one row per entity) ----
245
+ if not ineligible.empty:
246
+ ineligible_rows = (
247
+ ineligible[[entity_col]]
248
+ .drop_duplicates()
249
+ .assign(
250
+ R=np.nan,
251
+ cu=np.nan,
252
+ co=co_val,
253
+ under_cost=np.nan,
254
+ over_cost=np.nan,
255
+ diff=np.nan,
256
+ reason=f"min_n_not_met(<{policy.min_n})",
257
+ )
258
+ .copy()
259
+ )
260
+ ineligible_rows["n"] = ineligible_rows[entity_col].map(counts).astype(int)
261
+ else:
262
+ ineligible_rows = pd.DataFrame(
263
+ columns=[entity_col, "R", "cu", "co", "under_cost", "over_cost", "diff", "reason", "n"]
264
+ )
265
+
266
+ # ---- combine (avoid pandas FutureWarning on concat with empty/all-NA frames) ----
267
+ if ineligible_rows.empty:
268
+ out = tuned
269
+ elif tuned.empty:
270
+ out = ineligible_rows
271
+ else:
272
+ out = pd.concat([tuned, ineligible_rows], ignore_index=True, sort=False)
273
+
274
+ # ---- stable column ordering ----
275
+ base_cols = [entity_col, "R", "cu", "co", "n", "reason"]
276
+ diag_cols = ["under_cost", "over_cost", "diff"]
277
+ remaining = [c for c in out.columns if c not in base_cols + diag_cols]
278
+
279
+ cols = (base_cols + diag_cols + remaining) if include_diagnostics else (base_cols + remaining)
280
+
281
+ # Ensure all expected columns exist (even if empty)
282
+ for c in cols:
283
+ if c not in out.columns:
284
+ out[c] = np.nan
285
+
286
+ return out[cols]
287
+
288
+
289
+ __all__ = [
290
+ "CostRatioPolicy",
291
+ "DEFAULT_COST_RATIO_POLICY",
292
+ "apply_cost_ratio_policy",
293
+ "apply_entity_cost_ratio_policy",
294
+ ]
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Policy artifacts for the Readiness Adjustment Layer (RAL).
5
+
6
+ This module defines portable, immutable policy objects produced by offline
7
+ optimization and consumed by deterministic evaluation and production workflows.
8
+
9
+ Responsibilities:
10
+ - Represent learned RAL parameters (global and optional segment-level uplifts)
11
+ - Provide a stable, serializable contract between optimization and evaluation
12
+ - Support audit and governance workflows
13
+
14
+ Non-responsibilities:
15
+ - Learning or tuning parameters
16
+ - Applying policies to data
17
+ - Defining metric or loss functions
18
+
19
+ Design philosophy:
20
+ Policies are artifacts, not algorithms. They encode *decisions* derived from
21
+ optimization, not the optimization process itself.
22
+ """
23
+
24
+ from dataclasses import dataclass
25
+ from typing import Optional, Sequence
26
+ import pandas as pd
27
+
28
+ @dataclass(frozen=True)
29
+ class RALPolicy:
30
+ r"""Portable policy artifact for the Readiness Adjustment Layer (RAL).
31
+
32
+ A :class:`~eb_optimization.policies.ral_policy.RALPolicy` is the *output* of an
33
+ offline tuning process (e.g., grid search or evolutionary optimization) and the
34
+ *input* to deterministic evaluation / production application.
35
+
36
+ Conceptually, RAL applies a multiplicative uplift to a baseline forecast:
37
+
38
+ $$ \hat{y}^{(r)} = u \cdot \hat{y} $$
39
+
40
+ where `u` can be either:
41
+
42
+ - a **global uplift** (`global_uplift`), applied to all rows, and/or
43
+ - **segment-level** uplifts stored in `uplift_table`, keyed by `segment_cols`
44
+
45
+ Segment-level uplifts must fall back to the global uplift for unseen segment
46
+ combinations at application time.
47
+
48
+ Attributes
49
+ ----------
50
+ global_uplift
51
+ The global multiplicative uplift used as a fallback and baseline readiness adjustment.
52
+ segment_cols
53
+ The segmentation columns used to key `uplift_table`. Empty means "global-only".
54
+ uplift_table
55
+ Optional DataFrame with columns `[*segment_cols, "uplift"]` containing
56
+ segment-level uplifts. If `None` or empty, the policy is global-only.
57
+
58
+ Notes
59
+ -----
60
+ This dataclass is intentionally simple and serializable. It is meant to be:
61
+
62
+ - produced offline in `eb-optimization`
63
+ - applied deterministically in `eb-evaluation`
64
+ - loggable/auditable as part of operational governance
65
+
66
+ The policy does *not* encode metric definitions or optimization state—only the
67
+ artifacts needed to execute the adjustment.
68
+ """
69
+
70
+ global_uplift: float
71
+ segment_cols: Sequence[str] = ()
72
+ uplift_table: Optional[pd.DataFrame] = None
73
+
74
+ def is_segmented(self) -> bool:
75
+ """Return True if the policy contains segment-level uplifts."""
76
+ return bool(self.segment_cols) and self.uplift_table is not None and not self.uplift_table.empty
77
+
78
+ def adjust_forecast(self, df: pd.DataFrame, forecast_col: str) -> pd.Series:
79
+ """Apply the RAL policy to adjust the forecast values.
80
+
81
+ This method applies the global uplift to all rows, and applies segment-level uplifts
82
+ if the policy is segmented and matching segments exist in the `uplift_table`.
83
+
84
+ Parameters
85
+ ----------
86
+ df : pd.DataFrame
87
+ The input DataFrame containing the forecast to adjust.
88
+ forecast_col : str
89
+ The name of the column in `df` containing the forecast values to adjust.
90
+
91
+ Returns
92
+ -------
93
+ pd.Series
94
+ A series with the adjusted forecast values.
95
+ """
96
+ # Start with the global uplift applied to the forecast column
97
+ adjusted_forecast = df[forecast_col] * self.global_uplift
98
+
99
+ # Apply segment-level uplifts if available
100
+ if self.is_segmented():
101
+ # Merge uplift_table with the DataFrame based on segment columns
102
+ uplift_df = df.merge(self.uplift_table, on=self.segment_cols, how="left")
103
+ # Apply the segment-level uplift (if available) to the forecast
104
+ uplifted_forecast = adjusted_forecast * uplift_df["uplift"].fillna(1.0) # Default to 1.0 if no uplift
105
+ return uplifted_forecast
106
+ return adjusted_forecast
107
+
108
+ def transform(self, df: pd.DataFrame, forecast_col: str) -> pd.DataFrame:
109
+ """Transform the input DataFrame by applying the forecast adjustment.
110
+
111
+ This method applies the RAL policy to adjust the forecast column and adds a new column
112
+ with the adjusted forecast.
113
+
114
+ Parameters
115
+ ----------
116
+ df : pd.DataFrame
117
+ The input DataFrame containing the forecast to adjust.
118
+ forecast_col : str
119
+ The name of the column in `df` containing the forecast values to adjust.
120
+
121
+ Returns
122
+ -------
123
+ pd.DataFrame
124
+ The transformed DataFrame with the adjusted forecast values added.
125
+ """
126
+ df_copy = df.copy()
127
+ adjusted_forecast = self.adjust_forecast(df_copy, forecast_col)
128
+ df_copy["readiness_forecast"] = adjusted_forecast
129
+ return df_copy