diff --git a/HISTORY.md b/HISTORY.md index 5792995..680af18 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -7,8 +7,29 @@ and this project adheres to ## [Unreleased] +### Fixed + +- **Datasets** (`pr.datasets`) and **Download** (`pr.download`): + `williams_2018()` no longer conflates measured zeros with missing + values. Three defects, each masked by another: + - zeros in `.X` were coerced to `np.nan`, discarding 13,547 genuine + measurements + - charge-state summation used pandas' default `min_count=0`, so a + group with no measurements at all summed to `0.0`, inventing 3,324 + - the same summation skipped `NaN` inside a *partially* measured + group, reporting a partial total as complete for 260 cells + - **this changes `.X`**, and therefore the output of + `pr.download.williams_2018()`. Verified against the PRIDE deposit + used by the original publication: the two are now bit-identical + with an identical missingness pattern. + ### Changed +- **Datasets** (`pr.datasets`) and **Download** (`pr.download`): + `williams_2018()` gained a `zero_to_na` parameter (default `False`), + for consistency with sibling functions. It is mutually exclusive + with `fill_na`. Note it governs zero semantics only and does not + restore the summation defects above. - **Preprocessing** (`pr.pp`): `normalize_median()` - now defaults to `log_space=True` - renamed the `batch_id` parameter to `group_by` diff --git a/proteopy/datasets/williams_2018.py b/proteopy/datasets/williams_2018.py index a7ac606..ae6af74 100644 --- a/proteopy/datasets/williams_2018.py +++ b/proteopy/datasets/williams_2018.py @@ -16,6 +16,7 @@ def williams_2018( + zero_to_na: bool = False, fill_na: float | int | None = None, ) -> ad.AnnData: """Load Williams 2018 mouse multi-tissue proteomics dataset. @@ -30,6 +31,21 @@ def williams_2018( per peptide sequence. By default, missing values are represented as ``np.nan``. + Missing values and zeros are kept distinct, because + they mean different things: + + - A **zero** is a measurement, and is preserved + as ``0.0``. + - A **missing** value is the absence of one. + Charge-state summation propagates it, so a peptide + is quantified in a sample only if *every* one of + its charge states was quantified there; a + partially measured group yields ``np.nan`` rather + than a partial total. + + Pass ``zero_to_na=True`` to treat zeros as missing + instead. + Sample annotation (``.obs``) includes: - ``sample_id``: Unique sample identifier - ``tissue``: Tissue type (Brain, BAT, Heart, Liver, Quad) @@ -45,9 +61,14 @@ def williams_2018( Parameters ---------- + zero_to_na : bool, optional + If True, zeros in ``.X`` are treated as missing + values (NaN). Mutually exclusive with + ``fill_na``. fill_na : float | int | None, optional If not ``None``, replace ``np.nan`` in ``.X`` - with this value. + with this value. Mutually exclusive with + ``zero_to_na``. Returns ------- @@ -78,13 +99,20 @@ def williams_2018( Cellular Proteomics, 2018, 17(9):1766-1777. DOI: 10.1074/mcp.RA118.000554. """ + if not isinstance(zero_to_na, bool): + raise TypeError( + f"zero_to_na must be bool, got {type(zero_to_na).__name__}" + ) if fill_na is not None and not isinstance( - fill_na, (int, float), + fill_na, + (int, float), ): raise TypeError( f"fill_na must be float, int, or None, " f"got {type(fill_na).__name__}" ) + if zero_to_na and fill_na is not None: + raise ValueError("`zero_to_na` and `fill_na` are mutually exclusive.") url = ( "https://ars.els-cdn.com/content/image/" @@ -124,7 +152,8 @@ def williams_2018( # Select intensity columns: named cols where row 0 == "Intensity", # excluding _mito fractions intensity_cols = [ - c for c in df.columns + c + for c in df.columns if "Unnamed" not in str(c) and df[c].iloc[0] == "Intensity" and "_mito" not in str(c) @@ -133,29 +162,22 @@ def williams_2018( df = df[list(meta_cols.keys()) + intensity_cols] # Remove _WholeCell suffix from sample column names - df = df.rename(columns={ - c: c.replace("_WholeCell", "") - for c in intensity_cols - }) + df = df.rename( + columns={c: c.replace("_WholeCell", "") for c in intensity_cols} + ) df = df.rename(columns=meta_cols) # Drop the first row (secondary header) df = df.iloc[1:].reset_index(drop=True) # Extract peptide sequence (remove prefixes and suffixes) - df["peptide_id"] = ( - df["peptide_id"].str.split("_").str[1] - ) + df["peptide_id"] = df["peptide_id"].str.split("_").str[1] # Verify protein_id and gene_id are consistent # across charge states of the same peptide - meta_check = ( - df.groupby("peptide_id")[["protein_id", "gene_id"]] - .nunique() - ) + meta_check = df.groupby("peptide_id")[["protein_id", "gene_id"]].nunique() inconsistent = meta_check[ - (meta_check["protein_id"] > 1) - | (meta_check["gene_id"] > 1) + (meta_check["protein_id"] > 1) | (meta_check["gene_id"] > 1) ] if not inconsistent.empty: raise ValueError( @@ -164,45 +186,69 @@ def williams_2018( f"{inconsistent.index.tolist()}" ) - # Sum intensities across charge states of the same peptide + # Sum intensities across charge states of the same peptide. + # + # The sum PROPAGATES missing values: a peptide is quantified in a + # sample only if every one of its charge states was quantified + # there. `DataFrameGroupBy.sum()` cannot express this on its own -- + # it has no `skipna` argument, and `min_count` governs only the + # all-missing case -- so the sum is masked on the count. + # + # Both halves matter, and neither is sufficient alone: + # + # min_count=1 without it, a group whose charge states are ALL + # missing sums to 0.0, inventing a measurement + # that was never made. + # .where(complete) without it, a PARTIALLY measured group reports + # a partial total as though it were complete -- + # e.g. [NaN, 5000] -> 5000. sample_cols = [ - c for c in df.columns + c + for c in df.columns if c not in ("peptide_id", "protein_id", "gene_id") ] df[sample_cols] = df[sample_cols].astype(float) - var = ( - df.groupby("peptide_id")[["protein_id", "gene_id"]] - .first() - ) + grouped = df.groupby("peptide_id") + var = grouped[["protein_id", "gene_id"]].first() var["peptide_id"] = var.index - X = ( - df.groupby("peptide_id")[sample_cols] - .sum() - .values.T - ) + + intensities = grouped[sample_cols] + complete = intensities.count().eq(grouped.size(), axis=0) + X = intensities.sum(min_count=1).where(complete).values.T # Build obs annotation with tissue and mouse_id obs = pd.DataFrame({"sample_id": sample_cols}) parts = obs["sample_id"].str.split( - "_", n=1, expand=True, + "_", + n=1, + expand=True, ) parts.columns = ["p1", "p2"] - tissue_first = parts["p1"].str.fullmatch( - r"Brain|BAT|Heart|Liver|Quad" - ) + tissue_first = parts["p1"].str.fullmatch(r"Brain|BAT|Heart|Liver|Quad") obs["tissue"] = np.where( - tissue_first, parts["p1"], parts["p2"], + tissue_first, + parts["p1"], + parts["p2"], ) obs["mouse_id"] = np.where( - tissue_first, parts["p2"], parts["p1"], + tissue_first, + parts["p2"], + parts["p1"], ) obs = obs.set_index("sample_id") obs.index.name = None obs["sample_id"] = obs.index - # Construct anndata + # Construct anndata. + # + # NOTE: by default zeros are NOT coerced to NaN. A zero in this + # dataset is a measurement -- the peptide was looked for and its + # intensity was zero -- and is not interchangeable with "not + # measured". 13,547 of the 1,307,600 cells are genuine zeros. adata = ad.AnnData(X=X, obs=obs, var=var) - adata.X[adata.X == 0] = np.nan + + if zero_to_na: + adata.X[adata.X == 0] = np.nan if fill_na is not None: adata.X[np.isnan(adata.X)] = fill_na diff --git a/proteopy/download/williams_2018.py b/proteopy/download/williams_2018.py index d42afa5..915e2ff 100644 --- a/proteopy/download/williams_2018.py +++ b/proteopy/download/williams_2018.py @@ -7,16 +7,13 @@ _DEFAULT_INTENSITIES = ( - "williams-2018_ms-proteomics" - "_mouse-tissue_intensities.tsv" + "williams-2018_ms-proteomics" "_mouse-tissue_intensities.tsv" ) _DEFAULT_VAR = ( - "williams-2018_ms-proteomics" - "_mouse-tissue_peptide-annotation.tsv" + "williams-2018_ms-proteomics" "_mouse-tissue_peptide-annotation.tsv" ) _DEFAULT_SAMPLE = ( - "williams-2018_ms-proteomics" - "_mouse-tissue_sample-annotation.tsv" + "williams-2018_ms-proteomics" "_mouse-tissue_sample-annotation.tsv" ) @@ -25,6 +22,7 @@ def _check_williams_2018_types( var_annotation_path, sample_annotation_path, sep, + zero_to_na, fill_na, force, ): @@ -36,27 +34,27 @@ def _check_williams_2018_types( ): if not isinstance(value, (str, Path)): raise TypeError( - f"{name} must be str or Path, " - f"got {type(value).__name__}" + f"{name} must be str or Path, " f"got {type(value).__name__}" ) if sep is not None and not isinstance(sep, str): raise TypeError( - f"sep must be str or None, " - f"got {type(sep).__name__}" + f"sep must be str or None, " f"got {type(sep).__name__}" + ) + if not isinstance(zero_to_na, bool): + raise TypeError( + f"zero_to_na must be bool, " f"got {type(zero_to_na).__name__}" ) if fill_na is not None and ( - isinstance(fill_na, bool) - or not isinstance(fill_na, (int, float)) + isinstance(fill_na, bool) or not isinstance(fill_na, (int, float)) ): raise TypeError( f"fill_na must be float, int, or None, " f"got {type(fill_na).__name__}" ) + if zero_to_na and fill_na is not None: + raise ValueError("`zero_to_na` and `fill_na` are mutually exclusive.") if not isinstance(force, bool): - raise TypeError( - f"force must be bool, " - f"got {type(force).__name__}" - ) + raise TypeError(f"force must be bool, " f"got {type(force).__name__}") def _check_williams_2018_paths( @@ -101,6 +99,7 @@ def williams_2018( sample_annotation_path: str | Path = _DEFAULT_SAMPLE, *, sep: str | None = None, + zero_to_na: bool = False, fill_na: float | int | None = None, force: bool = False, ) -> None: @@ -135,9 +134,13 @@ def williams_2018( separator is inferred from each file extension via ``detect_separator_from_extension()`` (``.tsv`` → tab, ``.csv`` → comma). + zero_to_na : bool, optional + If True, zeros in the intensity matrix are treated as missing + values (NaN). Mutually exclusive with ``fill_na``. fill_na : float | int | None, optional If not ``None``, replace NaN values in the long-format - intensities DataFrame with this value before saving. + intensities DataFrame with this value before saving. Mutually + exclusive with ``zero_to_na``. force : bool, optional If ``True``, overwrite existing files at the output paths. Otherwise, raise ``FileExistsError`` when a @@ -171,6 +174,7 @@ def williams_2018( var_annotation_path, sample_annotation_path, sep, + zero_to_na, fill_na, force, ) @@ -183,7 +187,7 @@ def williams_2018( ) ) - adata = _load_williams_2018() + adata = _load_williams_2018(zero_to_na=zero_to_na) # Auto-detect separator from file extension if not provided if sep is None: @@ -222,33 +226,31 @@ def williams_2018( intensities_path, sep=sep_intensities, index=False, - lineterminator='\n', + lineterminator="\n", ) # Save .var annotation - df_var = adata.var[ - ["peptide_id", "protein_id", "gene_id"] - ].copy() + df_var = adata.var[["peptide_id", "protein_id", "gene_id"]].copy() var_annotation_path.parent.mkdir( - parents=True, exist_ok=True, + parents=True, + exist_ok=True, ) df_var.to_csv( var_annotation_path, sep=sep_var, index=False, - lineterminator='\n', + lineterminator="\n", ) # Save .obs annotation - df_obs = adata.obs[ - ["sample_id", "tissue", "mouse_id"] - ].copy() + df_obs = adata.obs[["sample_id", "tissue", "mouse_id"]].copy() sample_annotation_path.parent.mkdir( - parents=True, exist_ok=True, + parents=True, + exist_ok=True, ) df_obs.to_csv( sample_annotation_path, sep=sep_sample, index=False, - lineterminator='\n', + lineterminator="\n", ) diff --git a/tests/datasets/test_williams_2018.py b/tests/datasets/test_williams_2018.py index b4d0ada..d13da07 100644 --- a/tests/datasets/test_williams_2018.py +++ b/tests/datasets/test_williams_2018.py @@ -1,4 +1,5 @@ """Tests for proteopy.datasets.williams_2018.""" + import hashlib import anndata as ad @@ -13,28 +14,41 @@ _EXPECTED_SHAPE = (40, 32690) _EXPECTED_X_HASH = ( - "a2406828c5c11c28c566ac2bf9f694ac" - "eb90550ab37d91f085746b8b7fddf2c5" + "2f319804269569ce370f2f3477e3d957" "9118e389a07e27d06dfc2a7a798dfbc3" ) + +# Cell-state census of .X. These are pinned individually as well as via +# the hash, because the hash says only THAT the matrix changed, while +# these say WHAT it should contain -- and the distinction between a +# zero and a missing value is the thing most easily broken here. +_EXPECTED_N_NAN = 3584 +_EXPECTED_N_ZERO = 13547 +_EXPECTED_N_POSITIVE = 1290469 _EXPECTED_OBS_NAMES_HASH = ( - "4a510a6124dd8b917c42f4270353aee2" - "0a11fd97d0bbd38200319af5f6b602ee" + "4a510a6124dd8b917c42f4270353aee2" "0a11fd97d0bbd38200319af5f6b602ee" ) _EXPECTED_VAR_NAMES_HASH = ( - "35bac1a175466852feb110553409be8c" - "f56c6564aaa73a75e4dc910b1cbb2d0e" + "35bac1a175466852feb110553409be8c" "f56c6564aaa73a75e4dc910b1cbb2d0e" ) _EXPECTED_OBS_COLUMNS = ["tissue", "mouse_id", "sample_id"] _EXPECTED_VAR_COLUMNS = ["protein_id", "gene_id", "peptide_id"] _EXPECTED_TISSUES = ["BAT", "Brain", "Heart", "Liver", "Quad"] _EXPECTED_MOUSE_IDS = [ - "101", "45", "66", "68", "73", "80", "C57", "DBA", + "101", + "45", + "66", + "68", + "73", + "80", + "C57", + "DBA", ] # -- Fixtures -------------------------------------------------------- + @pytest.fixture(scope="module") def adata(): """Load williams_2018 dataset once for all tests.""" @@ -43,6 +57,7 @@ def adata(): # -- Helpers --------------------------------------------------------- + def _sha256(data: bytes) -> str: return hashlib.sha256(data).hexdigest() @@ -59,6 +74,7 @@ def _encode_index(index) -> bytes: # -- Content tests --------------------------------------------------- + class TestWilliams2018: """Verify structure and content of the williams_2018 dataset.""" @@ -75,32 +91,20 @@ def test_var_columns(self, adata): assert adata.var.columns.tolist() == _EXPECTED_VAR_COLUMNS def test_tissues(self, adata): - assert ( - sorted(adata.obs["tissue"].unique()) - == _EXPECTED_TISSUES - ) + assert sorted(adata.obs["tissue"].unique()) == _EXPECTED_TISSUES def test_mouse_ids(self, adata): - assert ( - sorted(adata.obs["mouse_id"].unique()) - == _EXPECTED_MOUSE_IDS - ) + assert sorted(adata.obs["mouse_id"].unique()) == _EXPECTED_MOUSE_IDS def test_eight_mice_per_tissue(self, adata): counts = adata.obs.groupby("tissue").size() assert (counts == 8).all() def test_obs_names_match_sample_id(self, adata): - assert ( - list(adata.obs_names) - == list(adata.obs["sample_id"]) - ) + assert list(adata.obs_names) == list(adata.obs["sample_id"]) def test_var_names_match_peptide_id(self, adata): - assert ( - list(adata.var_names) - == list(adata.var["peptide_id"]) - ) + assert list(adata.var_names) == list(adata.var["peptide_id"]) def test_x_dtype(self, adata): assert adata.X.dtype == np.float64 @@ -127,3 +131,36 @@ def test_fill_na_zero_removes_nan(self): def test_fill_na_string_raises(self): with pytest.raises(TypeError, match="fill_na must be"): williams_2018(fill_na="0") + + def test_zeros_are_preserved_not_coerced_to_nan(self, adata): + """A zero is a measurement and must survive as 0.0.""" + assert (adata.X == 0).sum() == _EXPECTED_N_ZERO + + def test_nan_count(self, adata): + assert np.isnan(adata.X).sum() == _EXPECTED_N_NAN + + def test_positive_count(self, adata): + assert (adata.X > 0).sum() == _EXPECTED_N_POSITIVE + + def test_cell_states_are_exhaustive(self, adata): + """No negative intensities, and the three states tile .X.""" + assert (adata.X < 0).sum() == 0 + assert ( + _EXPECTED_N_POSITIVE + _EXPECTED_N_ZERO + _EXPECTED_N_NAN + == adata.X.size + ) + + def test_zero_to_na_converts_zeros(self): + """Opt-in flag for callers who want zeros treated as missing.""" + result = williams_2018(zero_to_na=True) + assert (result.X == 0).sum() == 0 + assert np.isnan(result.X).sum() == _EXPECTED_N_NAN + _EXPECTED_N_ZERO + assert (result.X > 0).sum() == _EXPECTED_N_POSITIVE + + def test_zero_to_na_with_fill_na_raises(self): + with pytest.raises(ValueError, match="mutually exclusive"): + williams_2018(zero_to_na=True, fill_na=0) + + def test_zero_to_na_non_bool_raises(self): + with pytest.raises(TypeError, match="zero_to_na must be bool"): + williams_2018(zero_to_na="yes") diff --git a/tests/download/test_williams_2018.py b/tests/download/test_williams_2018.py index fb5f0ab..a877b1e 100644 --- a/tests/download/test_williams_2018.py +++ b/tests/download/test_williams_2018.py @@ -1,4 +1,5 @@ """Tests for proteopy.download.williams_2018.""" + import hashlib import pandas as pd @@ -9,35 +10,47 @@ # -- Expected values ------------------------------------------------- +# Changed by the missing-value fix in datasets.williams_2018. The var +# and sample annotation hashes below are deliberately UNCHANGED: the +# fix touches .X only, and their staying put is part of the evidence +# that it is correctly scoped. _EXPECTED_INTENSITIES_HASH = ( - "021410ece8505f9ef1181a4f1bbb5cde" - "c884011eba53a77e72cc6d6f51f1a531" + "0444cade741974e18c9d04ff4661bb7b" "6c84437cc23f2e643b097a1ad7844012" ) _EXPECTED_VAR_HASH = ( - "827b32fd2962cd18a7a990d56eab0e64" - "daa2a244b6226fe2d242106f185b2161" + "827b32fd2962cd18a7a990d56eab0e64" "daa2a244b6226fe2d242106f185b2161" ) _EXPECTED_SAMPLE_HASH = ( - "8cca98fa3a38df78b78912f3ef7daed5" - "7f82902485d61d90db5a823c1ed4f031" + "8cca98fa3a38df78b78912f3ef7daed5" "7f82902485d61d90db5a823c1ed4f031" ) _EXPECTED_INTENSITIES_COLUMNS = [ - "sample_id", "peptide_id", "intensity", + "sample_id", + "peptide_id", + "intensity", ] _EXPECTED_VAR_COLUMNS = [ - "peptide_id", "protein_id", "gene_id", + "peptide_id", + "protein_id", + "gene_id", ] _EXPECTED_SAMPLE_COLUMNS = [ - "sample_id", "tissue", "mouse_id", + "sample_id", + "tissue", + "mouse_id", ] _EXPECTED_TISSUES = [ - "BAT", "Brain", "Heart", "Liver", "Quad", + "BAT", + "Brain", + "Heart", + "Liver", + "Quad", ] # -- Helpers --------------------------------------------------------- + def _files(tmp_path, ext=".tsv"): return ( tmp_path / f"intensities{ext}", @@ -50,8 +63,17 @@ def _sha256(data: bytes) -> str: return hashlib.sha256(data).hexdigest() +def _is_zero(token: str) -> bool: + """True if a raw field parses to exactly 0.0, False if non-numeric.""" + try: + return float(token) == 0.0 + except ValueError: + return False + + # -- Content tests --------------------------------------------------- + class TestWilliams2018Download: """Verify downloaded file content, structure, and error handling.""" @@ -68,10 +90,7 @@ def test_files_created(self, files): def test_intensities_columns(self, files): df = pd.read_csv(files[0], sep="\t", nrows=0) - assert ( - df.columns.tolist() - == _EXPECTED_INTENSITIES_COLUMNS - ) + assert df.columns.tolist() == _EXPECTED_INTENSITIES_COLUMNS def test_var_annotation_columns(self, files): df = pd.read_csv(files[1], sep="\t", nrows=0) @@ -79,28 +98,37 @@ def test_var_annotation_columns(self, files): def test_sample_annotation_columns(self, files): df = pd.read_csv(files[2], sep="\t", nrows=0) - assert ( - df.columns.tolist() - == _EXPECTED_SAMPLE_COLUMNS - ) + assert df.columns.tolist() == _EXPECTED_SAMPLE_COLUMNS def test_intensities_hash(self, files): - assert ( - _sha256(files[0].read_bytes()) - == _EXPECTED_INTENSITIES_HASH - ) + assert _sha256(files[0].read_bytes()) == _EXPECTED_INTENSITIES_HASH def test_var_annotation_hash(self, files): - assert ( - _sha256(files[1].read_bytes()) - == _EXPECTED_VAR_HASH - ) + assert _sha256(files[1].read_bytes()) == _EXPECTED_VAR_HASH def test_sample_annotation_hash(self, files): - assert ( - _sha256(files[2].read_bytes()) - == _EXPECTED_SAMPLE_HASH + assert _sha256(files[2].read_bytes()) == _EXPECTED_SAMPLE_HASH + + def test_zeros_and_missing_survive_serialisation(self, files): + """A zero must round-trip as 0.0, not as an empty field. + + The dataset loader keeps zeros and missing values distinct; + this checks that writing to TSV does not collapse them again. + Read with pandas' NA handling disabled so that the two remain + distinguishable in the parsed frame. + """ + df = pd.read_csv( + files[0], + sep="\t", + dtype=str, + keep_default_na=False, + na_values=[], ) + values = df["intensity"] + n_zero = (values.map(_is_zero)).sum() + n_missing = (values == "").sum() + assert n_zero == 13547 + assert n_missing == 3584 def test_sample_count(self, files): df = pd.read_csv(files[2], sep="\t") @@ -108,28 +136,19 @@ def test_sample_count(self, files): def test_tissues_in_file(self, files): df = pd.read_csv(files[2], sep="\t") - assert ( - sorted(df["tissue"].unique()) - == _EXPECTED_TISSUES - ) + assert sorted(df["tissue"].unique()) == _EXPECTED_TISSUES def test_csv_extension_uses_comma(self, tmp_path): p = _files(tmp_path, ext=".csv") williams_2018(*p) df = pd.read_csv(p[0], sep=",", nrows=0) - assert ( - df.columns.tolist() - == _EXPECTED_INTENSITIES_COLUMNS - ) + assert df.columns.tolist() == _EXPECTED_INTENSITIES_COLUMNS def test_tsv_extension_uses_tab(self, tmp_path): p = _files(tmp_path, ext=".tsv") williams_2018(*p) df = pd.read_csv(p[0], sep="\t", nrows=0) - assert ( - df.columns.tolist() - == _EXPECTED_INTENSITIES_COLUMNS - ) + assert df.columns.tolist() == _EXPECTED_INTENSITIES_COLUMNS def test_file_exists_error(self, tmp_path): p = _files(tmp_path) @@ -145,9 +164,7 @@ def test_force_overwrites(self, tmp_path): williams_2018(*p, force=True) for path in p: assert path.read_bytes() != dummy - assert ( - _sha256(p[0].read_bytes()) == _EXPECTED_INTENSITIES_HASH - ) + assert _sha256(p[0].read_bytes()) == _EXPECTED_INTENSITIES_HASH assert _sha256(p[1].read_bytes()) == _EXPECTED_VAR_HASH assert _sha256(p[2].read_bytes()) == _EXPECTED_SAMPLE_HASH @@ -158,7 +175,8 @@ def test_overlapping_paths_raises(self, tmp_path): def test_invalid_path_type_raises(self, tmp_path): with pytest.raises( - TypeError, match="must be str or Path", + TypeError, + match="must be str or Path", ): williams_2018( 123, @@ -169,21 +187,24 @@ def test_invalid_path_type_raises(self, tmp_path): def test_invalid_sep_type_raises(self, tmp_path): p = _files(tmp_path) with pytest.raises( - TypeError, match="sep must be str or None", + TypeError, + match="sep must be str or None", ): williams_2018(*p, sep=123) def test_fill_na_bool_raises(self, tmp_path): p = _files(tmp_path) with pytest.raises( - TypeError, match="fill_na must be", + TypeError, + match="fill_na must be", ): williams_2018(*p, fill_na=True) def test_force_non_bool_raises(self, tmp_path): p = _files(tmp_path) with pytest.raises( - TypeError, match="force must be bool", + TypeError, + match="force must be bool", ): williams_2018(*p, force=1) @@ -193,6 +214,30 @@ def test_fill_na_zero_removes_nan(self, tmp_path): df = pd.read_csv(p[0], sep="\t") assert not df["intensity"].isna().any() + def test_zero_to_na_removes_zeros(self, tmp_path): + p = _files(tmp_path) + williams_2018(*p, zero_to_na=True) + df = pd.read_csv( + p[0], + sep="\t", + dtype=str, + keep_default_na=False, + na_values=[], + ) + values = df["intensity"] + assert values.map(_is_zero).sum() == 0 + assert (values == "").sum() == 3584 + 13547 + + def test_zero_to_na_with_fill_na_raises(self, tmp_path): + p = _files(tmp_path) + with pytest.raises(ValueError, match="mutually exclusive"): + williams_2018(*p, zero_to_na=True, fill_na=0) + + def test_zero_to_na_non_bool_raises(self, tmp_path): + p = _files(tmp_path) + with pytest.raises(TypeError, match="zero_to_na must be bool"): + williams_2018(*p, zero_to_na="yes") + def test_default_intensities_contain_nan(self, tmp_path): p = _files(tmp_path) williams_2018(*p)