Skip to content

Commit 59ec63a

Browse files
add type hints; bump
1 parent 55d2a91 commit 59ec63a

File tree

6 files changed

+171
-22
lines changed

6 files changed

+171
-22
lines changed

AGENTS.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Repository Guidelines
2+
3+
## Project Structure & Module Organization
4+
This is a small Python package with a `src/` layout:
5+
- `src/grpredict/__init__.py`: core implementation (`CultureGrowthEKF`, moving statistics helpers).
6+
- `src/grpredict/__init__.pyi` and `src/grpredict/py.typed`: typing surface for consumers.
7+
- `tests/`: pytest suite (`test_ekf.py`, `test_moving_accumulators.py`).
8+
- `build/` and `dist/`: packaging artifacts; treat as generated output.
9+
10+
Keep new code close to existing modules unless there is a clear boundary that justifies a new file.
11+
12+
## Build, Test, and Development Commands
13+
Use the local virtual environment (`.venv`) when available.
14+
15+
```bash
16+
source .venv/bin/activate
17+
pip install -e ".[dev]" # editable install + pytest/black/mypy
18+
pytest -v # full test suite (matches CI)
19+
pytest tests/test_ekf.py -q # run one file during iteration
20+
python -m mypy src # type-check package code
21+
python -m build # build sdist/wheel into dist/
22+
```
23+
24+
CI (`.github/workflows/ci.yaml`) runs tests on Python 3.11 with `pytest -v`.
25+
26+
## Coding Style & Naming Conventions
27+
- Follow standard Python style: 4-space indentation, `snake_case` for functions/variables, `PascalCase` for classes.
28+
- Preserve explicit type hints on public functions and tests.
29+
- Keep functions focused and readable; prefer straightforward logic over clever abstractions.
30+
- Use `black` formatting conventions (configured via default behavior unless specified otherwise).
31+
32+
## Testing Guidelines
33+
- Framework: `pytest`.
34+
- Test files must be named `test_*.py`; test functions should start with `test_`.
35+
- Add targeted tests for behavior changes before broad refactors.
36+
- During development, run individual tests first, then the full suite before opening a PR.
37+
38+
## Commit & Pull Request Guidelines
39+
Recent history favors short, imperative commit subjects (for example: `adding ci`, `Update README.md`).
40+
- Keep commit titles concise and action-oriented.
41+
- In PRs, include: problem statement, implementation summary, and test evidence (`pytest` output or equivalent).
42+
- Link related tickets/issues when applicable and call out any API/behavior changes explicitly.
43+
44+
## Security & Configuration Tips
45+
- Never commit secrets or local environment files (`.env`, `.envrc`).
46+
- Validate changes against typed interfaces (`.pyi`/`py.typed`) when modifying public APIs.

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
## 26.2.10
2+
3+
Adding type hints
4+
5+
## 25.6.1
6+
7+
Initial release

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "grpredict"
7-
version = "25.6.1"
7+
version = "26.2.10"
88
description = "Estimate growth curves using an Extended Kalman Filter"
99
readme = "README.md"
1010
requires-python = ">=3.8"
@@ -43,6 +43,8 @@ dev = [
4343
"mypy"
4444
]
4545

46+
[tool.setuptools.package-data]
47+
grpredict = ["py.typed", "*.pyi"]
4648

4749
[tool.setuptools.packages.find]
48-
where = ["src"]
50+
where = ["src"]

src/grpredict/__init__.py

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
# -*- coding: utf-8 -*-
22
from __future__ import annotations
3+
from collections.abc import Sequence
34
from math import sqrt
5+
from typing import Any
46
from typing import Optional
7+
from typing import TypeAlias
8+
9+
FloatVectorLike: TypeAlias = Any
10+
FloatMatrixLike: TypeAlias = Any
511

612
class ExponentialMovingAverage:
713
"""
@@ -12,7 +18,7 @@ class ExponentialMovingAverage:
1218
Ex: if alpha = 0, use latest value only.
1319
"""
1420

15-
def __init__(self, alpha: float):
21+
def __init__(self, alpha: float) -> None:
1622
if alpha < 0 or alpha > 1:
1723
raise ValueError
1824
self.value: Optional[float] = None
@@ -51,7 +57,7 @@ def __init__(
5157
ema_alpha: Optional[float] = None,
5258
initial_std_value: Optional[float] = None,
5359
initial_mean_value: Optional[float] = None,
54-
):
60+
) -> None:
5561
self._var_value = initial_std_value**2 if initial_std_value is not None else None
5662
self.value: Optional[float] = initial_std_value if initial_std_value is not None else None
5763
self.alpha = alpha
@@ -195,16 +201,19 @@ class CultureGrowthEKF:
195201

196202
def __init__(
197203
self,
198-
initial_state,
199-
initial_covariance,
200-
process_noise_covariance,
201-
observation_noise_covariance,
202-
angles: list[str],
204+
initial_state: FloatVectorLike,
205+
initial_covariance: FloatMatrixLike,
206+
process_noise_covariance: FloatMatrixLike,
207+
observation_noise_covariance: FloatMatrixLike,
208+
angles: Sequence[str],
203209
outlier_std_threshold: float,
204210
) -> None:
205211
import numpy as np
206212

207-
initial_state = np.asarray(initial_state)
213+
initial_state = np.asarray(initial_state, dtype=float)
214+
initial_covariance = np.asarray(initial_covariance, dtype=float)
215+
process_noise_covariance = np.asarray(process_noise_covariance, dtype=float)
216+
observation_noise_covariance = np.asarray(observation_noise_covariance, dtype=float)
208217

209218
assert initial_state.shape[0] == 2
210219
assert (
@@ -228,10 +237,15 @@ def __init__(
228237
0.975, 0.80, initial_std_value=np.sqrt(observation_noise_covariance[0][0])
229238
)
230239

231-
def update(self, obs: list[float], dt: float, recent_dilution=False):
240+
def update(
241+
self,
242+
obs: FloatVectorLike,
243+
dt: float,
244+
recent_dilution: bool = False,
245+
) -> tuple[FloatVectorLike, FloatMatrixLike]:
232246
import numpy as np
233247

234-
observation = np.asarray(obs)
248+
observation = np.asarray(obs, dtype=float)
235249
assert observation.shape[0] == self.n_sensors, (observation, self.n_sensors)
236250

237251
# Predict
@@ -287,7 +301,6 @@ def update(self, obs: list[float], dt: float, recent_dilution=False):
287301

288302
# update gr process covariance if required
289303
nis = (residual_state[0] ** 2) / residual_covariance[0, 0]
290-
291304
if nis > _NIS_THRESHOLD:
292305
self.process_noise_covariance[1, 1] *= 2
293306
else:
@@ -300,13 +313,14 @@ def update(self, obs: list[float], dt: float, recent_dilution=False):
300313

301314
return self.state_, self.covariance_
302315

303-
def update_observation_noise_cov(self, residual_state):
316+
def update_observation_noise_cov(self, residual_state: FloatVectorLike) -> FloatMatrixLike:
304317
"""
305318
Exponentially-weighted measurement noise covariance.
306319
"""
307320
import numpy as np
308321

309322
lambda_ = 0.97 # controls the “memory” of the estimator. 0.97 means the filter effectively averages the last ≈ 1 / (1-0.97) ≈ 33 timesteps.
323+
residual_state = np.asarray(residual_state, dtype=float)
310324
rrT = np.outer(residual_state, residual_state)
311325

312326
observation_noise_covariance = lambda_ * self.observation_noise_covariance + (1.0 - lambda_) * rrT
@@ -317,7 +331,7 @@ def update_observation_noise_cov(self, residual_state):
317331

318332
return observation_noise_covariance
319333

320-
def update_state_from_previous_state(self, state, dt: float):
334+
def update_state_from_previous_state(self, state: FloatVectorLike, dt: float) -> FloatVectorLike:
321335
"""
322336
Denoted "f" in literature, x_{k} = f(x_{k-1})
323337
@@ -329,10 +343,10 @@ def update_state_from_previous_state(self, state, dt: float):
329343
"""
330344
import numpy as np
331345

332-
od, rate = state
346+
od, rate = np.asarray(state, dtype=float)
333347
return np.array([od * np.exp(rate * dt), rate])
334348

335-
def _J_update_observations_from_state(self, state_prediction):
349+
def _J_update_observations_from_state(self, state_prediction: FloatVectorLike) -> FloatMatrixLike:
336350
"""
337351
Jacobian of observations model, encoded as update_observations_from_state
338352
@@ -362,7 +376,17 @@ def _J_update_observations_from_state(self, state_prediction):
362376
J[i, 0] = 1.0 if (angle != "180") else -exp(-(od - 1))
363377
return J
364378

365-
def update_covariance_from_old_covariance(self, state, covariance, dt: float, recent_dilution: bool):
379+
def update_covariance_from_old_covariance(
380+
self,
381+
state: FloatVectorLike,
382+
covariance: FloatMatrixLike,
383+
dt: float,
384+
recent_dilution: bool,
385+
) -> FloatMatrixLike:
386+
import numpy as np
387+
388+
state = np.asarray(state, dtype=float)
389+
covariance = np.asarray(covariance, dtype=float)
366390
Q = self.process_noise_covariance.copy().astype(float)
367391

368392
if recent_dilution:
@@ -372,7 +396,7 @@ def update_covariance_from_old_covariance(self, state, covariance, dt: float, re
372396
jacobian = self._J_update_state_from_previous_state(state, dt)
373397
return jacobian @ covariance @ jacobian.T + Q
374398

375-
def update_observations_from_state(self, state_predictions):
399+
def update_observations_from_state(self, state_predictions: FloatVectorLike) -> FloatVectorLike:
376400
"""
377401
"h" in the literature, z_k = h(x_k).
378402
@@ -388,7 +412,7 @@ def update_observations_from_state(self, state_predictions):
388412
obs[i] = od if (angle != "180") else np.exp(-(od - 1))
389413
return obs
390414

391-
def _J_update_state_from_previous_state(self, state, dt: float):
415+
def _J_update_state_from_previous_state(self, state: FloatVectorLike, dt: float) -> FloatMatrixLike:
392416
"""
393417
The prediction process is (encoded in update_state_from_previous_state)
394418
@@ -410,7 +434,7 @@ def _J_update_state_from_previous_state(self, state, dt: float):
410434

411435
J = np.zeros((2, 2))
412436

413-
od, rate = state
437+
od, rate = np.asarray(state, dtype=float)
414438
J[0, 0] = np.exp(rate * dt)
415439
J[1, 1] = 1
416440

@@ -419,9 +443,10 @@ def _J_update_state_from_previous_state(self, state, dt: float):
419443
return J
420444

421445
@staticmethod
422-
def _is_positive_definite(A) -> bool:
446+
def _is_positive_definite(A: FloatMatrixLike) -> bool:
423447
import numpy as np
424448

449+
A = np.asarray(A, dtype=float)
425450
if np.array_equal(A, A.T):
426451
try:
427452
return True

src/grpredict/__init__.pyi

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from collections.abc import Iterable, Sequence
2+
from typing import ClassVar, TypeAlias
3+
4+
FloatVectorLike: TypeAlias = Iterable[float]
5+
FloatMatrixLike: TypeAlias = Iterable[Iterable[float]]
6+
7+
8+
class ExponentialMovingAverage:
9+
value: float | None
10+
alpha: float
11+
def __init__(self, alpha: float) -> None: ...
12+
def update(self, new_value: float) -> float: ...
13+
def get_latest(self) -> float: ...
14+
def clear(self) -> None: ...
15+
16+
17+
class ExponentialMovingStd:
18+
value: float | None
19+
alpha: float
20+
ema: ExponentialMovingAverage
21+
def __init__(
22+
self,
23+
alpha: float,
24+
ema_alpha: float | None = None,
25+
initial_std_value: float | None = None,
26+
initial_mean_value: float | None = None,
27+
) -> None: ...
28+
def update(self, new_value: float) -> float | None: ...
29+
def get_latest(self) -> float: ...
30+
def clear(self) -> None: ...
31+
32+
33+
class CultureGrowthEKF:
34+
handle_outliers: ClassVar[bool]
35+
process_noise_covariance: object
36+
observation_noise_covariance: object
37+
state_: object
38+
covariance_: object
39+
n_sensors: int
40+
n_states: int
41+
angles: Sequence[str]
42+
outlier_std_threshold: float
43+
44+
def __init__(
45+
self,
46+
initial_state: FloatVectorLike,
47+
initial_covariance: FloatMatrixLike,
48+
process_noise_covariance: FloatMatrixLike,
49+
observation_noise_covariance: FloatMatrixLike,
50+
angles: Sequence[str],
51+
outlier_std_threshold: float,
52+
) -> None: ...
53+
def update(
54+
self, obs: FloatVectorLike, dt: float, recent_dilution: bool = False
55+
) -> tuple[object, object]: ...
56+
def update_observation_noise_cov(self, residual_state: FloatVectorLike) -> object: ...
57+
def update_state_from_previous_state(self, state: FloatVectorLike, dt: float) -> object: ...
58+
def _J_update_observations_from_state(self, state_prediction: FloatVectorLike) -> object: ...
59+
def update_covariance_from_old_covariance(
60+
self,
61+
state: FloatVectorLike,
62+
covariance: FloatMatrixLike,
63+
dt: float,
64+
recent_dilution: bool,
65+
) -> object: ...
66+
def update_observations_from_state(self, state_predictions: FloatVectorLike) -> object: ...
67+
def _J_update_state_from_previous_state(self, state: FloatVectorLike, dt: float) -> object: ...
68+
@staticmethod
69+
def _is_positive_definite(A: FloatMatrixLike) -> bool: ...

src/grpredict/py.typed

Whitespace-only changes.

0 commit comments

Comments
 (0)