Skip to content
Merged
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
24 changes: 24 additions & 0 deletions .github/workflows/mypy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: mypy

on: [push, pull_request]

jobs:
type-check:
runs-on: ubuntu-latest

steps:
- name: Checkout repo
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"

- name: Install package + dev deps
run: |
pip install .[dev]

- name: Run MyPy
run: |
mypy src/ecoff_fitter --pretty
21 changes: 1 addition & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Demo input files are provided in `demo_files/` to illustrate basic use.

---

## 📦 Installation
## 🛠 Installation

### Install from PyPI

Expand All @@ -39,25 +39,6 @@ pip install -e .

---

## 🛠 Creating the Environment

### Conda environment (env.yml)

```bash
conda env create -f env.yml
conda activate ECOFFitter
```

### Pip environment (requirements.txt)

```bash
python -m venv ecoff-env
source ecoff-env/bin/activate
pip install -r requirements.txt
```

---

## 📥 Input

### 1. MIC Data Input File
Expand Down
17 changes: 17 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,20 @@ omit = [
"*/ecoff_fitter/wts.py",
"*/gui.py"
]

[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_ignores = true
warn_redundant_casts = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
ignore_missing_imports = true
allow_redefinition = true
no_implicit_optional = true
implicit_reexport = true
exclude = "src/ecoff_fitter/wts.py"

[project.optional-dependencies]
dev = ["mypy", "pandas-stubs", "types-PyYAML", "scipy-stubs"]
10 changes: 0 additions & 10 deletions requirements.txt

This file was deleted.

7 changes: 5 additions & 2 deletions src/ecoff_fitter/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from typing import Callable


"""
ECOFF Fitter — Estimate epidemiological cutoff values (ECOFFs)
using interval regression on MIC (Minimum Inhibitory Concentration) data.
Expand All @@ -16,7 +19,7 @@
# --- Public API imports ---
from .core import ECOFFitter

__all__ = ["ECOFFitter"]
__all__: list[str] = ["ECOFFitter"]

# --- Optional: version handling ---
try:
Expand All @@ -25,7 +28,7 @@
__version__ = "0.0.0"

# --- Optional: CLI hook for `python -m ecoff_fitter` ---
def main():
def main() -> None:
"""Entry point for running ecoff_fitter as a module (CLI)."""
from .cli import main as cli_main
cli_main()
Expand Down
3 changes: 2 additions & 1 deletion src/ecoff_fitter/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""

import argparse
from typing import Any, List, Optional
from ecoff_fitter import ECOFFitter
from ecoff_fitter.report import GenerateReport
from ecoff_fitter.defence import validate_output_path
Expand Down Expand Up @@ -74,7 +75,7 @@ def build_parser() -> argparse.ArgumentParser:
return parser


def main(argv=None):
def main(argv: Optional[List[str]] = None) -> None:
"""Main entry point for the ECOFFitter CLI."""
parser = build_parser()
args = parser.parse_args(argv)
Expand Down
45 changes: 35 additions & 10 deletions src/ecoff_fitter/core.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import numpy as np
from typing import Any, Optional, Tuple
import pandas as pd
from numpy.typing import NDArray
from scipy.stats import norm
from intreg.intreg import IntReg
from ecoff_fitter.utils import read_input, read_params
Expand All @@ -24,14 +27,28 @@ class ECOFFitter:
(wild-type) component at the given percentile.
"""

model_: IntReg | MixtureModel | None
x: NDArray[np.floating]
mus_: NDArray[np.floating]
sigmas_: NDArray[np.floating]
pis_: NDArray[np.floating]
loglike_: float
converged_: bool
n_iter_: int | None
ecoff_: float
z_percentile_: float
y_low_: NDArray[np.floating]
y_high_: NDArray[np.floating]
weights_: NDArray[np.floating]

def __init__(
self,
input,
params: dict | str | None = None,
input: pd.DataFrame | str,
params: dict[str, Any] | str | None = None,
dilution_factor: int = 2,
distributions: int = 1,
boundary_support: int | None = 1,
):
) -> None:
"""
Initialize the ECOFFitter.

Expand Down Expand Up @@ -75,7 +92,7 @@ def __init__(
self.distributions = distributions
self.boundary_support = boundary_support

def fit(self, options={}):
def fit(self, options: dict[str, Any] | None = None) -> "ECOFFitter":
"""
Define MIC intervals and fit either a single censored-normal model
or a finite mixture model.
Expand All @@ -96,7 +113,7 @@ def fit(self, options={}):
# multiple gaussians
return self.fit_mixture(options)

def fit_single(self, options=None):
def fit_single(self, options: dict[str, Any] | None = None) -> "ECOFFitter":
"""
Fit a single-component censored normal distribution using interval
regression.
Expand All @@ -122,7 +139,9 @@ def fit_single(self, options=None):
self.converged_ = result.success
self.n_iter_ = result.nit if hasattr(result, "nit") else None

def fit_mixture(self, options=None):
return self

def fit_mixture(self, options: dict[str, Any] | None = None) -> "ECOFFitter":
"""
Fit a K-component finite mixture of censored normals using the EM
algorithm followed by optional refinement.
Expand Down Expand Up @@ -156,7 +175,9 @@ def fit_mixture(self, options=None):

return self

def define_intervals(self, df=None):
def define_intervals(
self, df: Optional[pd.DataFrame] = None
) -> Tuple[NDArray[np.floating], NDArray[np.floating], NDArray[np.floating]]:
"""
Construct MIC interval bounds and apply left-, right-, and interval-
censoring rules, then transform to log dilution space.
Expand Down Expand Up @@ -210,7 +231,9 @@ def define_intervals(self, df=None):

return y_low_log, y_high_log, weights

def log_transf_intervals(self, y_low, y_high):
def log_transf_intervals(
self, y_low: NDArray[np.floating], y_high: NDArray[np.floating]
) -> Tuple[NDArray[np.floating], NDArray[np.floating]]:
"""
Transform interval bounds into log base–dilution_factor space.

Expand All @@ -231,7 +254,9 @@ def log_transf_intervals(self, y_low, y_high):

return y_low_log, y_high_log

def generate(self, percentile: int | float = 99, options={}):
def generate(
self, percentile: int | float = 99, options: dict[str, Any] | None = None
) -> Tuple[Any, ...]:
"""
Fit the model and compute the ECOFF at a specified percentile.

Expand All @@ -253,7 +278,7 @@ def generate(self, percentile: int | float = 99, options={}):

return results

def compute_ecoff(self, percentile: float):
def compute_ecoff(self, percentile: float) -> Tuple[Any, ...]:
"""
Compute the ECOFF and percentile location from the fitted model.

Expand Down
29 changes: 17 additions & 12 deletions src/ecoff_fitter/defence.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import pandas as pd
from typing import Any
from pandas import DataFrame
import os
import re


def validate_input_source(input):
def validate_input_source(input: str | DataFrame | dict[str, Any]) -> None:
"""
Validate the input source for ECOFFitter.

Expand Down Expand Up @@ -32,7 +34,9 @@ def validate_input_source(input):
raise ValueError("Input must be a pandas DataFrame or a valid file path.")


def validate_params_source(params):
def validate_params_source(
params: dict[str, Any] | str | list[Any] | tuple[Any, ...] | DataFrame | Any | None,
) -> None:
"""
Pre-validate the params argument before attempting to read it.

Expand Down Expand Up @@ -69,8 +73,7 @@ def validate_params_source(params):
)



def validate_mic_data(df):
def validate_mic_data(df: DataFrame) -> None:
"""
Validate MIC and observations columns.

Expand All @@ -97,7 +100,9 @@ def validate_mic_data(df):
raise ValueError(f"Invalid MIC format found in rows: {bad_rows.index.tolist()}")


def validate_params(dilution_factor, distributions, boundary_support):
def validate_params(
dilution_factor: int, distributions: int, boundary_support: int | None
) -> None:
"""
Validate ECOFFitter configuration values.

Expand All @@ -112,29 +117,30 @@ def validate_params(dilution_factor, distributions, boundary_support):

if not isinstance(dilution_factor, int) or dilution_factor <= 1:
raise ValueError("dilution_factor must be an integer > 1.")

if not isinstance(distributions, int):
raise NotImplementedError("The number of mixture components must be an integer.")

raise NotImplementedError(
"The number of mixture components must be an integer."
)

if boundary_support is not None and (
not isinstance(boundary_support, int) or boundary_support < 0
):
raise ValueError("boundary_support must be a non-negative integer or None.")



def validate_output_path(path: str) -> bool:
"""
Checks if the given path is safe and writable, and that the file extension is .txt or .pdf.

Returns True if valid, otherwise raises ValueError.
"""
# Check extension
allowed_exts = ('.txt', '.pdf')
allowed_exts = (".txt", ".pdf")
if not path.lower().endswith(allowed_exts):
raise ValueError(f"File must end with {allowed_exts}, got '{path}'")

directory = os.path.dirname(path) or '.'
directory = os.path.dirname(path) or "."

if not os.path.exists(directory):
raise ValueError(f"Directory does not exist: {directory}")
Expand All @@ -143,4 +149,3 @@ def validate_output_path(path: str) -> bool:
raise PermissionError(f"No write permission in directory: {directory}")

return True

27 changes: 15 additions & 12 deletions src/ecoff_fitter/graphs.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import numpy as np
from typing import Optional
from numpy.typing import NDArray
import matplotlib.axes
import matplotlib.pyplot as plt
from scipy.stats import norm


def plot_mic_distribution(
low_log,
high_log,
weights,
dilution_factor,
mus,
sigmas,
pis=None,
log2_ecoff=None,
global_x_min=None,
global_x_max=None,
ax=None,
):
low_log: NDArray[np.floating],
high_log: NDArray[np.floating],
weights: NDArray[np.floating],
dilution_factor: float | int,
mus: NDArray[np.floating] | list[float],
sigmas: NDArray[np.floating] | list[float],
pis: Optional[NDArray[np.floating] | list[float]] = None,
log2_ecoff: Optional[float] = None,
global_x_min: Optional[float] = None,
global_x_max: Optional[float] = None,
ax: Optional[matplotlib.axes.Axes] = None,
) -> matplotlib.axes.Axes:
"""
Plot MIC intervals with a K-component Gaussian mixture fit.
Supports left- and right-censoring with visual tail extensions.
Expand Down
Loading
Loading