From a0ffa7e9c7c335f84f3371f2251e1948327c5d81 Mon Sep 17 00:00:00 2001 From: jbloom Date: Mon, 3 Nov 2025 15:07:26 -0800 Subject: [PATCH 1/4] test on Python 3.13 and require >=3.9 --- .github/workflows/test.yml | 2 +- CHANGELOG.rst | 6 ++++++ docs/installation.rst | 2 +- neutcurve/__init__.py | 2 +- setup.py | 4 ++-- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 141e6f2..8d2ca03 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: - name: install python uses: actions/setup-python@v4 with: - python-version: "3.11" + python-version: "3.13" - name: install package and dependencies run: pip install -e . && pip install -r test_requirements.txt diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a20bc10..1554876 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,12 @@ All notable changes to this project will be documented in this file. The format is based on `Keep a Changelog `_. +2.3.0 +----- +Added ++++++ +- Start testing on Python 3.13, and up minimum version to 3.9. + 2.2.0 ----- diff --git a/docs/installation.rst b/docs/installation.rst index 5ebc4cb..88abd2b 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -1,7 +1,7 @@ Installation -------------- -`neutcurve` requires Python 3.8 or newer. +`neutcurve` requires Python 3.9 or newer. The easiest way to install `neutcurve` is from `PyPI `_ using `pip `_ with:: diff --git a/neutcurve/__init__.py b/neutcurve/__init__.py index 6e660e0..ef580c1 100644 --- a/neutcurve/__init__.py +++ b/neutcurve/__init__.py @@ -16,7 +16,7 @@ __author__ = "Jesse Bloom" __email__ = "jbloom@fredhutch.org" -__version__ = "2.2.0" +__version__ = "2.3.0" __url__ = "https://github.com/jbloomlab/neutcurve" from neutcurve.curvefits import CurveFits # noqa: F401 diff --git a/setup.py b/setup.py index 4d453aa..b8dda0f 100644 --- a/setup.py +++ b/setup.py @@ -11,9 +11,9 @@ except ImportError: raise ImportError("You must install `setuptools`") -if not (sys.version_info[0] == 3 and sys.version_info[1] >= 8): +if not (sys.version_info[0] == 3 and sys.version_info[1] >= ): raise RuntimeError( - "neutcurve requires Python 3.8 or higher.\n" + "neutcurve requires Python 3.9 or higher.\n" f"You are using Python {sys.version_info[0]}.{sys.version_info[1]}" ) From 90a76c581e708887a5950f75ddb4ff1c7c6771aa Mon Sep 17 00:00:00 2001 From: jbloom Date: Mon, 3 Nov 2025 15:15:26 -0800 Subject: [PATCH 2/4] remove obsoleted `parse_excel` module --- CHANGELOG.rst | 4 + neutcurve/parse_excel.py | 198 --------------------------------------- 2 files changed, 4 insertions(+), 198 deletions(-) delete mode 100644 neutcurve/parse_excel.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1554876..9e172a5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,6 +12,10 @@ Added +++++ - Start testing on Python 3.13, and up minimum version to 3.9. +Removed ++++++++ +- Removed obsolete ``parse_excel`` module. + 2.2.0 ----- diff --git a/neutcurve/parse_excel.py b/neutcurve/parse_excel.py deleted file mode 100644 index 1423650..0000000 --- a/neutcurve/parse_excel.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -================== -parse_excel -================== - -Defines functions for parsing data directly from Excel in specific formats. -""" - -import os - -import pandas as pd - - -def parseRachelStyle2019( - *, - excelfile, - sheet_mapping, -): - """Parse data from Rachel Eguia's 2019-style assays on Bloom lab Tecan. - - Args: - `excelfile` (str) - Excel file with data exactly as produced by plate reader. - `sheet_mapping` (dict) - Describes data in each sheet of `excelfile`. There should - be a key matching the name of each Excel sheet. The value - for each key is another dict with the following keys / values - describing the data for that plate (we assume one sheet per - serum / virus pair): - - - 'serum': name of serum. - - 'virus': name of virus. - - 'dilution_factor': fold-dilution of serum along plate. - - 'initial_concentration': serum concentration in first well - used to start dilution series. - - 'excluded_dilutions' (optional) : list of dilutions to - exclude from returned results, with 1 being least dilute - and 12 being most dilute. Useful if some columns - in plate have a known problem. - - Returns: - A pandas DataFrame in the tidy format read by - :class:`neutcurve.curvefits.CurveFits`. - - """ - if not os.path.isfile(excelfile): - raise ValueError(f"cannot find `excelfile` {excelfile}") - - # choose engine: https://stackoverflow.com/a/65266270/4191652 - if os.path.splitext(excelfile)[-1] == ".xls": - engine = "xlrd" - elif os.path.splitext(excelfile)[-1] == ".xlsx": - engine = "openpyxl" - else: - raise ValueError(f"invalid extension in `excelfile` {excelfile}") - sheet_data = pd.read_excel( - excelfile, - sheet_name=None, # read all sheets - engine=engine, - skiprows=range(0, 30), - index_col=0, - nrows=8, - ) - - # keys of sheet_mapping should be str - sheet_mapping = {str(key): val for key, val in sheet_mapping.items()} - - extra_sheets = set(sheet_data) - set(sheet_mapping) - if extra_sheets: - raise ValueError( - f"`excelfile` {excelfile} has the following extra " - f"sheets not in `sheet_mapping`: {extra_sheets}" - ) - - neutdata = [] - req_keys = ["dilution_factor", "initial_concentration", "serum", "virus"] - optional_keys = ["excluded_dilutions"] - for sheet, sheet_info in sheet_mapping.items(): - if sheet not in sheet_data: - raise ValueError( - f"sheet {sheet} specified in `sheet_mapping` " - f"is not present in `excelfile` {excelfile}" - ) - - extra_keys = set(sheet_info.keys()) - set(req_keys + optional_keys) - if extra_keys: - raise ValueError( - f"`sheet_mapping` has extra keys {extra_keys} " - f"for sheet {sheet}. Allowed keys are {req_keys} " - f"and optionally {optional_keys}." - ) - - kwargs = {"layout": "RachelStyle2019"} - for key in req_keys: - try: - kwargs[key] = sheet_info[key] - except KeyError: - raise ValueError( - f"`sheet_mapping` does not specify {key} " f"for sheet {sheet}" - ) - for key in optional_keys: - if key in sheet_info: - kwargs[key] = sheet_info[key] - - neutdata.append( - _parse_BloomLab_TecanPlate( - sheet_df=sheet_data[sheet], - sheet_name_for_err=f"sheet {sheet} of {excelfile}", - **kwargs, - ) - ) - - return pd.concat(neutdata, ignore_index=True, sort=False) - - -def _parse_BloomLab_TecanPlate( - *, - sheet_df, - sheet_name_for_err, - initial_concentration, - dilution_factor, - layout, - serum, - virus, - excluded_dilutions=(), -): - """Process data frame for one 96-well plate of Bloom lab Tecan.""" - # expected rows / columns - rows = ["A", "B", "C", "D", "E", "F", "G", "H"] - ncols = 12 - all_cols = list(range(1, ncols + 1)) - if layout == "RachelStyle2019": - # reverse excluded dilution columns if assay dilutes columns in reverse - exclude_cols = {ncols - col + 1 for col in excluded_dilutions} - else: - raise ValueError(f"invalid layout of {layout}") - if exclude_cols > set(all_cols): - raise ValueError("invalid col to exclude") - retained_cols = [col for col in all_cols if col not in exclude_cols] - if not retained_cols: - raise ValueError("no cols retained after `exclude_cols`") - - # check for expected rows / columns - if sheet_df.index.tolist() != rows: - raise ValueError( - f"{sheet_name_for_err} does not have expected rows.\n" - f"Expected: {rows}\nGot: {sheet_df.index.tolist()}" - ) - if sheet_df.columns.tolist() != all_cols: - raise ValueError( - f"{sheet_name_for_err} does not have expected " - f"columns.\nExpected: {all_cols}\n" - f"Got: {sheet_df.columns.tolist()}" - ) - if not all(pd.api.types.is_numeric_dtype(sheet_df[c]) for c in all_cols): - raise ValueError(f"Entries in {sheet_name_for_err} not all numerical") - - sheet_df = sheet_df[retained_cols] - - if layout == "RachelStyle2019": - reps = { - rep: sheet_df.loc[row] for rep, row in [("1", "D"), ("2", "E"), ("3", "F")] - } - no_serum = (sheet_df.loc["C"] + sheet_df.loc["G"]) / 2 - virus_only = sheet_df.loc["B"] - media_only = (sheet_df.loc["A"] + sheet_df.loc["H"]) / 2 - if any(media_only > virus_only): - raise ValueError( - f"In {sheet_name_for_err}, the media-only " - f"control has more signal than the virus-only " - f"control, which is unexpected:\n{sheet_df}" - ) - # compute fraction infectivity for each replicate - data = { - rep: (repval - virus_only) / (no_serum - virus_only) - for rep, repval in reps.items() - } - data["concentration"] = [ - initial_concentration / dilution_factor ** (ncols - col) - for col in retained_cols - ] - df = pd.DataFrame(data).melt( - id_vars="concentration", - var_name="replicate", - value_name="fraction infectivity", - ) - else: - raise ValueError(f"invalid `layout` of {layout}") - - return df.assign(serum=serum, virus=virus)[ - ["serum", "virus", "replicate", "concentration", "fraction infectivity"] - ] - - -if __name__ == "__main__": - import doctest - - doctest.testmod() From 902a48aa18ab8423e6d2d1bd4855abfa0422a928 Mon Sep 17 00:00:00 2001 From: jbloom Date: Mon, 3 Nov 2025 16:43:03 -0800 Subject: [PATCH 3/4] Added `marimo_utils.display_fig_marimo` to enable easy display of large curves via `marimo` --- CHANGELOG.rst | 1 + neutcurve/marimo_utils.py | 114 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 neutcurve/marimo_utils.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9e172a5..fbf6461 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,7 @@ The format is based on `Keep a Changelog `_. Added +++++ - Start testing on Python 3.13, and up minimum version to 3.9. +- Added ``marimo_utils.display_fig_marimo`` to enable easy display of large curves via `marimo` Removed +++++++ diff --git a/neutcurve/marimo_utils.py b/neutcurve/marimo_utils.py new file mode 100644 index 0000000..a41dc16 --- /dev/null +++ b/neutcurve/marimo_utils.py @@ -0,0 +1,114 @@ +""" +============ +marimo_utils +============ + +Utilities for working with `marimo `_ notebooks. +""" + +import base64 +import io + +import matplotlib +import matplotlib.figure + + +def display_fig_marimo(fig, display_method): + """Display large matplotlib figure via marimo. + + This function is designed when you have a very large matplotlib figure (eg, as + produced by the plotting functions of :class:`neutcurve.curvefits.CurveFits`) + and you want to display it in marimo notebook. + + Running this function requires you to have separately installed + `marimo `_ and (if you are using `display_method="png8") + `pillow `_ + + Args: + `fig` (matplotlib..figure.Figure) + The figure we want to display. + `display_method` {"inline", "svg", "pdf", "png8"} + Display the figure just inline, as a SVG, as a PDF, or as a PNG8. + In general, displaying as a PNG8 will be the smallest size although + also the lowest resolution. + + Returns: + output_obj + The returned object can be display in marimo, via + ``marimo.output.append(output_obj)`` + + """ + if not isinstance(fig, matplotlib.figure.Figure): + raise ValueError( + f"Expected `fig` to be matplotlib.figure.Figure, instead {type(fig)=}" + ) + + if display_method == "inline": + return fig + + import marimo as mo + + if display_method == "svg": + buf = io.BytesIO() + with matplotlib.rc_context( + { + "svg.fonttype": "none", # keep text as text, not paths + "svg.image_inline": True, # embed small images if present + "svg.hashsalt": "fixed-1", # deterministic ids in the SVG + "path.simplify": True, + "path.simplify_threshold": 0.2, + } + ): + fig.savefig(buf, format="svg", metadata={}) + svg_text = buf.getvalue().decode("utf-8") + return mo.Html( + f""" + +
+ {svg_text} +
+""" + ) + + elif display_method == "pdf": + buf = io.BytesIO() + with matplotlib.rc_context( + { + "pdf.fonttype": 42, + "pdf.compression": 7, + "path.simplify": True, + "path.simplify_threshold": 0.2, + } + ): + fig.savefig(buf, format="pdf", metadata={}) + buf.seek(0) + return mo.pdf(src=buf, width="100%", height="80vh") + + elif display_method == "png8": + import PIL + + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=80, metadata={}) + im = PIL.Image.open(io.BytesIO(buf.getvalue())).quantize( + colors=48, dither=PIL.Image.NONE + ) + out = io.BytesIO() + im.save(out, format="PNG", optimize=True, pnginfo=PIL.PngImagePlugin.PngInfo()) + data = base64.b64encode(out.getvalue()).decode("ascii") + return mo.Html(f'figure') + + else: + raise ValueError(f"Invalid {display_method=}") + + +if __name__ == "__main__": + import doctest + + doctest.testmod() From a06591c1ed17a8884af009a3d29a30caf584715c Mon Sep 17 00:00:00 2001 From: jbloom Date: Mon, 3 Nov 2025 18:32:15 -0800 Subject: [PATCH 4/4] fix bug in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b8dda0f..00b5d43 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ except ImportError: raise ImportError("You must install `setuptools`") -if not (sys.version_info[0] == 3 and sys.version_info[1] >= ): +if not (sys.version_info[0] == 3 and sys.version_info[1] >= 9): raise RuntimeError( "neutcurve requires Python 3.9 or higher.\n" f"You are using Python {sys.version_info[0]}.{sys.version_info[1]}"