Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ those changes.

## [Unreleased]

## [1.40.2] - 2026-06-12

### Fixed

- Docstring inconsistencies in three numerical helpers (no behaviour
change): `bivariate.find_elbow_point` now documents both of its
non-integer return sentinels (`-1` when every value in `x` or `y` is
missing, `np.nan` when the accumulated intersection points are
degenerate) in a proper NumPy-style `Returns` section, alongside a
new `Parameters` and `Raises` section; `monitoring.control_charts.rho`
and `monitoring.control_charts.psi` gain NumPy-style `Parameters`,
`Returns`, and `References` sections documenting `x`, the tuning
constant `k`, and the return value (the paper reference is preserved).

## [1.40.1] - 2026-06-11

### Fixed
Expand Down Expand Up @@ -1997,7 +2011,8 @@ this entry records them together.
- Reworked the README with a sharper value proposition and a
"Why not scikit-learn?" comparison table.

[Unreleased]: https://github.com/kgdunn/process-improve/compare/v1.40.1...HEAD
[Unreleased]: https://github.com/kgdunn/process-improve/compare/v1.40.2...HEAD
[1.40.2]: https://github.com/kgdunn/process-improve/compare/v1.40.1...v1.40.2
[1.40.1]: https://github.com/kgdunn/process-improve/compare/v1.40.0...v1.40.1
[1.40.0]: https://github.com/kgdunn/process-improve/compare/v1.39.0...v1.40.0
[1.39.0]: https://github.com/kgdunn/process-improve/compare/v1.38.4...v1.39.0
Expand Down
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ authors:
repository-code: "https://github.com/kgdunn/process-improve"
url: "https://kgdunn.github.io/process-improve/"
license: MIT
version: 1.40.1
date-released: "2026-06-11"
version: 1.40.2
date-released: "2026-06-12"
keywords:
- chemometrics
- multivariate analysis
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "process-improve"
version = "1.40.1"
version = "1.40.2"
description = 'Designed Experiments; Latent Variables (PCA, PLS, multivariate methods with missing data); Process Monitoring; Batch data analysis.'
readme = "README.md"
license = "MIT"
Expand Down
44 changes: 34 additions & 10 deletions src/process_improve/bivariate/_elbow_peak.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ def find_elbow_point(x: np.ndarray, y: np.ndarray, max_iter: int = 41) -> int |
"""
Find the elbow point when plotting numeric entries in `x` vs numeric values in list `y`.

Return the index into the vectors `x` and `y` [the vectors must have the same length], where
the elbow point occurs. Returns -1 if every value in `x` or `y` is missing.

Using a robust linear fit, sorts the samples in X (independent variable)
and takes the first 5 samples from the left, and the last 5 from the right,
then fits two linear regressions and computes the intersection of the two
Expand All @@ -32,6 +29,38 @@ def find_elbow_point(x: np.ndarray, y: np.ndarray, max_iter: int = 41) -> int |
Will probably not work well on few data points. If so, try fitting a spline
to the raw data and then repeat with the interpolated data.

Parameters
----------
x : np.ndarray
Independent-variable values. Must have the same length as `y`. NaN
entries are dropped pairwise with `y` before fitting. After dropping
missing values, more than 10 finite values must remain.
y : np.ndarray
Dependent-variable values. Must have the same length as `x`. NaN
entries are dropped pairwise with `x` before fitting.
max_iter : int, optional
Number of evenly spaced window sizes to sweep when accumulating
intersection points. Default is 41.

Returns
-------
int or float
The integer index into the (sorted, NaN-stripped) `x` / `y` vectors of
the data point closest to the consensus elbow location. Two non-integer
sentinel return values are possible:

* ``-1`` is returned when every value in `x` or `y` is missing (the
median of `x` or `y` is NaN), so no elbow can be estimated.
* ``np.nan`` is returned when the accumulated intersection points are
themselves degenerate (every fitted pair of lines was effectively
parallel, so the median intersection in `x` or `y` is NaN).

Raises
------
ValueError
If `x` and `y` have different lengths, or if fewer than 11 finite
values remain after NaN entries are dropped.

"""
start = 5
# assert divmod(max_iter, 2)[1] # must be odd number; to ensure we calculate the median later
Expand All @@ -47,9 +76,7 @@ def calculate_line_length(x1: float, y1: float, x2: np.ndarray, y2: np.ndarray)
x = np.array(x.copy())
y = np.array(y.copy())
if len(x) != len(y):
raise ValueError(
f"x and y must have the same length; got len(x)={len(x)}, len(y)={len(y)}."
)
raise ValueError(f"x and y must have the same length; got len(x)={len(x)}, len(y)={len(y)}.")

# Stop if everything is missing:
if np.isnan(np.nanmedian(x)) or np.isnan(np.nanmedian(y)):
Expand All @@ -58,9 +85,7 @@ def calculate_line_length(x1: float, y1: float, x2: np.ndarray, y2: np.ndarray)
# Eliminate missing values in x and y simultaneously.
x, y = x[~(np.isnan(x) | np.isnan(y))], y[~(np.isnan(x) | np.isnan(y))]
if len(x) <= 10:
raise ValueError(
"Requires more than 10 values in the vectors (not including missing data)."
)
raise ValueError("Requires more than 10 values in the vectors (not including missing data).")
idx_sort = x.argsort()
x = x[idx_sort]
y = y[idx_sort]
Expand Down Expand Up @@ -118,7 +143,6 @@ def find_line_intersection(m1: float, b1: float, m2: float, b2: float) -> tuple:
return x, y



# ENG-23 (#305): explicit ``__all__`` so the thin re-exporter ``methods.py``
# can do ``from ._elbow_peak import *`` without triggering CodeQL's
# py/polluting-import warning.
Expand Down
50 changes: 42 additions & 8 deletions src/process_improve/monitoring/control_charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,24 @@ def rho(x: float, k: float = 2.52) -> float:
"""
Bi-weight rho function.

Fixed constant of k=2.52 is from p 289 of the paper
Parameters
----------
x : float
Value at which to evaluate the bi-weight rho function.
k : float, optional
Bi-weight tuning constant. The default of 2.52 is from p 289 of the
referenced paper.

Returns
-------
float
The value of the bi-weight rho function evaluated at `x` with tuning
constant `k`. For ``|x| > k`` the function saturates at `k`.

References
----------
https://onlinelibrary.wiley.com/doi/abs/10.1002/for.1125

"""
return k if np.abs(x) > k else k * (1 - np.power(1 - np.power(x / k, 2), 3))

Expand All @@ -27,8 +43,25 @@ def psi(x: float, k: float = 2.0) -> float:
Pre-clean based on the Huber y-function.

Can be interpreted as replacing unexpected high or low values by a more likely value.
From p 288 of the paper

Parameters
----------
x : float
Value to clean.
k : float, optional
Huber tuning constant: the threshold beyond which `x` is clipped to
``k * sign(x)``. The default of 2.0 follows p 288 of the referenced
paper.

Returns
-------
float
``x`` itself when ``|x| < k``; otherwise ``k * sign(x)``.

References
----------
https://onlinelibrary.wiley.com/doi/abs/10.1002/for.1125

"""
return x if abs(x) < k else k * np.sign(x)

Expand Down Expand Up @@ -85,7 +118,11 @@ def __init__(self, style: str = "robust", variant: str = "HW") -> None:
self.df = pd.DataFrame(columns=columns, dtype=np.float64)

def calculate_limits( # noqa: C901 - branch count is mostly simple input-validation guard clauses
self, y: np.ndarray | pd.Series, target: float | None = None, s: float | None = None, **kwargs,
self,
y: np.ndarray | pd.Series,
target: float | None = None,
s: float | None = None,
**kwargs,
) -> None:
"""
Find for a given vector `y`, the control chart target and limits.
Expand Down Expand Up @@ -116,8 +153,7 @@ def calculate_limits( # noqa: C901 - branch count is mostly simple input-valid
self.s = float(s)
if not 0.0 < s < 1e300:
raise ValueError(
"The given standard deviation must be positive and not excessively large "
f"(0 < s < 1e300); got {s}."
f"The given standard deviation must be positive and not excessively large (0 < s < 1e300); got {s}."
)

if target is not None:
Expand Down Expand Up @@ -315,9 +351,7 @@ def _holt_winters_warmup_fit(self, ld_1: float = 0.5, ld_2: float = 0.8, ld_s: f
warm_up_residuals = y_warm_up - self.warm_up["alpha_0"] - self.warm_up["beta_0"]

# Some other method that does not rely on SciPy for 1 function.
self.warm_up["sigma_0"] = median_absolute_deviation(
np.asarray(warm_up_residuals), nan_policy="omit"
)
self.warm_up["sigma_0"] = median_absolute_deviation(np.asarray(warm_up_residuals), nan_policy="omit")
self.warm_up["residuals"] = warm_up_residuals

# A constant (zero-variance) warm-up window gives sigma_0 = MAD = 0, which
Expand Down
Loading