diff --git a/plotnado/_kwargs.py b/plotnado/_kwargs.py index 1c4e167..76fe453 100644 --- a/plotnado/_kwargs.py +++ b/plotnado/_kwargs.py @@ -674,6 +674,10 @@ class QuantnadoCoverageKwargs(TypedDict, total=False): color_group: str | None quantnado: Any | None dataset_path: str | None + scaling_factor: float + normalise: str | None + normalize: str | None + library_sizes: Series | dict | None coverage_data: Any | None color: str alpha: float @@ -713,6 +717,10 @@ class QuantnadoStrandedCoverageKwargs(TypedDict, total=False): color_group: str | None quantnado: Any | None dataset_path: str | None + scaling_factor: float + normalise: str | None + normalize: str | None + library_sizes: Series | dict | None coverage_fwd_data: Any | None coverage_rev_data: Any | None color: str diff --git a/plotnado/figure.py b/plotnado/figure.py index c0d154e..239c224 100644 --- a/plotnado/figure.py +++ b/plotnado/figure.py @@ -355,6 +355,10 @@ def autocolor(self, palette: str | list[str] | None = None) -> Self: self._apply_autocolor() return self + def defaults(self, genome: str = "hg38", palette: str | list[str] | None = None) -> Self: + """Apply the standard scaffold: autocolor, scalebar, and genes.""" + return self.autocolor(palette=palette).scalebar().genes(genome) + def _apply_autocolor(self) -> None: if self._autocolor_palette is None: return @@ -555,7 +559,13 @@ def plot( hspace = self.theme.subplot_hspace if self.theme is not None else 0.08 gs = GridSpec(len(main_tracks), 1, height_ratios=heights, hspace=hspace) - axes = [fig.add_subplot(gs[index]) for index in range(len(main_tracks))] + axes: list[matplotlib.axes.Axes] = [] + for index in range(len(main_tracks)): + if index == 0: + ax = fig.add_subplot(gs[index]) + else: + ax = fig.add_subplot(gs[index], sharex=axes[0]) + axes.append(ax) def create_overlay_ax(zorder: int, label: str): ax = fig.add_axes(axes[0].get_position(), label=label, zorder=zorder) diff --git a/plotnado/figure.pyi b/plotnado/figure.pyi index cd98f22..132a6ad 100644 --- a/plotnado/figure.pyi +++ b/plotnado/figure.pyi @@ -699,8 +699,12 @@ class GenomicFigure: height: float = 1.0, autoscale_group: str | None = None, color_group: str | None = None, + scaling_factor: float = 1.0, quantnado: Any | None = None, dataset_path: str | None = None, + normalise: str | None = None, + normalize: str | None = None, + library_sizes: pd.Series | dict | None = None, coverage_data: Any | None = None, color: str = '#2171b5', alpha: float = 0.75, @@ -739,8 +743,12 @@ class GenomicFigure: height: float = 1.0, autoscale_group: str | None = None, color_group: str | None = None, + scaling_factor: float = 1.0, quantnado: Any | None = None, dataset_path: str | None = None, + normalise: str | None = None, + normalize: str | None = None, + library_sizes: pd.Series | dict | None = None, coverage_fwd_data: Any | None = None, coverage_rev_data: Any | None = None, color: str = '#1f78b4', @@ -869,6 +877,7 @@ class GenomicFigure: def autoscale(self, enable: bool = ...) -> Self: ... def autocolor(self, palette: str = ...) -> Self: ... + def defaults(self, genome: str = ..., palette: str | list[str] | None = ...) -> Self: ... def highlight(self, region: str | GenomicRegion) -> Self: ... def highlight_style(self, color: str | None = ..., alpha: float | None = ...) -> Self: ... diff --git a/plotnado/figure_methods.py b/plotnado/figure_methods.py index ffb24b7..a4f5bba 100644 --- a/plotnado/figure_methods.py +++ b/plotnado/figure_methods.py @@ -1079,7 +1079,11 @@ def quantnado_coverage( data: Any | None = ..., dataset_path: str | None = ..., height: float = ..., + library_sizes: Series | dict | None = ..., + normalise: str | None = ..., + normalize: str | None = ..., quantnado: Any | None = ..., + scaling_factor: float = ..., title: str | None = ..., alpha: float = ..., baseline_alpha: float = ..., @@ -1140,7 +1144,11 @@ def quantnado_stranded_coverage( data: Any | None = ..., dataset_path: str | None = ..., height: float = ..., + library_sizes: Series | dict | None = ..., + normalise: str | None = ..., + normalize: str | None = ..., quantnado: Any | None = ..., + scaling_factor: float = ..., title: str | None = ..., alpha: float = ..., baseline_alpha: float = ..., diff --git a/plotnado/tracks/base.py b/plotnado/tracks/base.py index 50dc1f0..564f8ec 100644 --- a/plotnado/tracks/base.py +++ b/plotnado/tracks/base.py @@ -436,8 +436,9 @@ def _plot_title(self, ax: matplotlib.axes.Axes, gr: GenomicRegion) -> None: if self.title_location == "left" else gr.end - (0.01 * gr.length) ) - y_pos = self.y_delta * self.title_height + y_pos = self.y_min + (self.y_delta * self.title_height) h_align = "left" if self.title_location == "left" else "right" + title_bbox = self._text_bbox() or self._default_text_bbox(0.6) ax.text( x_pos, @@ -445,7 +446,7 @@ def _plot_title(self, ax: matplotlib.axes.Axes, gr: GenomicRegion) -> None: self.title, horizontalalignment=h_align, verticalalignment="top", - bbox=self._text_bbox(), + bbox=title_bbox, fontdict={ "size": self.title_size, "color": self.title_color, @@ -478,14 +479,15 @@ def _plot_scale_at( else gr.start + (0.01 * gr.length) ) h_align = "right" if location == "right" else "left" + scale_bbox = self._text_bbox() or self._default_text_bbox(0.6) ax.text( x_pos, - self.y_delta * self.scale_height, + self.y_min + (self.y_delta * self.scale_height), f"[ {y_min} - {y_max} ]", horizontalalignment=h_align, verticalalignment="top", - bbox=self._text_bbox(), + bbox=scale_bbox, fontdict={ "size": self.scale_size, "color": self.scale_color, diff --git a/plotnado/tracks/quantnado.py b/plotnado/tracks/quantnado.py index 74224ee..d6f6691 100644 --- a/plotnado/tracks/quantnado.py +++ b/plotnado/tracks/quantnado.py @@ -7,6 +7,7 @@ import matplotlib.axes import numpy as np +import pandas as pd from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator from .base import Track, TrackLabeller @@ -204,6 +205,22 @@ class QuantNadoCoverageTrack(_QuantNadoSourceMixin, Track): >>> QuantNadoCoverageTrack(sample="tumor", dataset_path="study.qn") """ sample: str = Field(description="Sample name to render from QuantNado data.") + scaling_factor: float = Field( + default=1.0, + description="User-defined multiplicative factor applied to the extracted coverage signal.", + ) + normalise: str | None = Field( + default=None, + description="Optional normalization mode passed to QuantNado extract_region, e.g. 'cpm' or 'rpkm'.", + ) + normalize: str | None = Field( + default=None, + description="American-English alias for `normalise`.", + ) + library_sizes: pd.Series | dict | None = Field( + default=None, + description="Optional library sizes forwarded to QuantNado normalization.", + ) coverage_data: Any | None = Field( default=None, description="Optional precomputed xarray-like coverage data with dims (sample, position).", @@ -226,13 +243,25 @@ def _validate_source(self) -> "QuantNadoCoverageTrack": def fetch_data(self, gr: GenomicRegion) -> dict[str, np.ndarray]: if self.coverage_data is not None: positions, values = _extract_series(self.coverage_data, self.sample, gr) + values = values * float(self.scaling_factor) return {"position": positions, "value": values} qn = self._resolve_quantnado() if qn is None: raise RuntimeError("No QuantNado source available for coverage track") - data = qn.extract_region(region=_region_string(gr), samples=[self.sample]) + coverage_store = getattr(qn, "coverage", None) + if coverage_store is None: + raise RuntimeError("QuantNado source has no coverage store for coverage track") + data = coverage_store.extract_region( + region=_region_string(gr), + samples=[self.sample], + as_xarray=False, + normalise=self.normalise, + normalize=self.normalize, + library_sizes=self.library_sizes, + ) positions, values = _extract_series(data, self.sample, gr) + values = values * float(self.scaling_factor) return {"position": positions, "value": values} def plot(self, ax: matplotlib.axes.Axes, gr: GenomicRegion) -> None: @@ -293,6 +322,22 @@ class QuantNadoStrandedCoverageTrack(_QuantNadoSourceMixin, Track): >>> QuantNadoStrandedCoverageTrack(sample="tumor", dataset_path="study.qn") """ sample: str = Field(description="Sample name to render from QuantNado data.") + scaling_factor: float = Field( + default=1.0, + description="User-defined multiplicative factor applied to forward and reverse coverage signals.", + ) + normalise: str | None = Field( + default=None, + description="Optional normalization mode passed to QuantNado coverage store extraction.", + ) + normalize: str | None = Field( + default=None, + description="American-English alias for `normalise`.", + ) + library_sizes: pd.Series | dict | None = Field( + default=None, + description="Optional library sizes forwarded to QuantNado normalization.", + ) coverage_fwd_data: Any | None = Field( default=None, description="Optional forward-strand xarray-like coverage data with dims (sample, position).", @@ -323,6 +368,9 @@ def fetch_data(self, gr: GenomicRegion) -> dict[str, np.ndarray]: if self.coverage_fwd_data is not None and self.coverage_rev_data is not None: pos_fwd, fwd = _extract_series(self.coverage_fwd_data, self.sample, gr) pos_rev, rev = _extract_series(self.coverage_rev_data, self.sample, gr) + factor = float(self.scaling_factor) + fwd = fwd * factor + rev = rev * factor if pos_fwd.size and pos_rev.size and np.array_equal(pos_fwd, pos_rev): pos = pos_fwd elif pos_fwd.size: @@ -339,10 +387,29 @@ def fetch_data(self, gr: GenomicRegion) -> dict[str, np.ndarray]: raise RuntimeError("QuantNado source has no coverage store for stranded coverage track") region = _region_string(gr) - fwd_data = coverage_store.extract_region(region=region, samples=[self.sample], strand="+") - rev_data = coverage_store.extract_region(region=region, samples=[self.sample], strand="-") + fwd_data = coverage_store.extract_region( + region=region, + samples=[self.sample], + as_xarray=False, + strand="+", + normalise=self.normalise, + normalize=self.normalize, + library_sizes=self.library_sizes, + ) + rev_data = coverage_store.extract_region( + region=region, + samples=[self.sample], + as_xarray=False, + strand="-", + normalise=self.normalise, + normalize=self.normalize, + library_sizes=self.library_sizes, + ) pos_fwd, fwd = _extract_series(fwd_data, self.sample, gr) pos_rev, rev = _extract_series(rev_data, self.sample, gr) + factor = float(self.scaling_factor) + fwd = fwd * factor + rev = rev * factor if pos_fwd.size and pos_rev.size and np.array_equal(pos_fwd, pos_rev): pos = pos_fwd elif pos_fwd.size: diff --git a/pyproject.toml b/pyproject.toml index 38500aa..e7d6830 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,12 +8,8 @@ authors = [{ name = "Alastair Smith", email = "alastair.smith@ndcls.ox.ac.uk" }] description = "A simple plotting library for making genomic tracks" readme = "README.md" requires-python = ">=3.12" -license = "GPL-3.0-or-later" +license = { text = "GPL-3.0-or-later" } dynamic = ["version", "dependencies"] -scripts = { "plotnado" = "plotnado.cli.cli:main" } - -[tool.setuptools.dynamic] -dependencies = { file = ["requirements.txt"] } keywords = ["genomics", "bioinformatics", "plotting", "genome-browser", "tracks"] classifiers = [ "Development Status :: 3 - Alpha", @@ -24,6 +20,10 @@ classifiers = [ "Topic :: Scientific/Engineering :: Bio-Informatics", "Topic :: Scientific/Engineering :: Visualization", ] +scripts = { "plotnado" = "plotnado.cli.cli:main" } + +[tool.setuptools.dynamic] +dependencies = { file = ["requirements.txt"] } [project.optional-dependencies] cooler = ["cooler", "capcruncher"] diff --git a/tests/test_quantnado_tracks.py b/tests/test_quantnado_tracks.py index 328f661..5710f6f 100644 --- a/tests/test_quantnado_tracks.py +++ b/tests/test_quantnado_tracks.py @@ -61,7 +61,8 @@ def __init__( self.coverage_calls: list[dict] = [] self._coverage = coverage coverage_responses = {"+": coverage_fwd, "-": coverage_rev} - self.coverage = _RecordingStore({k: v for k, v in coverage_responses.items() if v is not None}) + stranded_coverage = {k: v for k, v in coverage_responses.items() if v is not None} + self.coverage = _RecordingStore(stranded_coverage or coverage) self.methylation = _RecordingStore( {"methylation_pct": methylation} if methylation is not None else {} ) @@ -164,11 +165,26 @@ def test_coverage_calls_quantnado_extract_region(self): gr = GenomicRegion(chromosome="chr1", start=100, end=105) da = _make_dataarray([1, 2, 3, 4, 5]) qn = _RecordingQuantNado(coverage=da) - track = QuantNadoCoverageTrack(sample="s1", quantnado=qn) + track = QuantNadoCoverageTrack( + sample="s1", + quantnado=qn, + scaling_factor=2.0, + normalize="rpkm", + ) fetched = track.fetch_data(gr) assert fetched["position"].tolist() == [100, 101, 102, 103, 104] - assert qn.coverage_calls == [{"region": "chr1:100-105", "samples": ["s1"]}] + assert fetched["value"].tolist() == [2.0, 4.0, 6.0, 8.0, 10.0] + assert qn.coverage.calls == [ + { + "region": "chr1:100-105", + "samples": ["s1"], + "as_xarray": False, + "normalise": None, + "normalize": "rpkm", + "library_sizes": None, + } + ] def test_stranded_calls_coverage_store_with_strands(self): gr = GenomicRegion(chromosome="chr1", start=100, end=103) @@ -177,14 +193,35 @@ def test_stranded_calls_coverage_store_with_strands(self): coverage_fwd=_make_dataarray([1, 2, 3]), coverage_rev=_make_dataarray([3, 2, 1]), ) - track = QuantNadoStrandedCoverageTrack(sample="s1", quantnado=qn) + track = QuantNadoStrandedCoverageTrack( + sample="s1", + quantnado=qn, + scaling_factor=0.5, + normalise="cpm", + ) fetched = track.fetch_data(gr) - assert fetched["forward"].tolist() == [1.0, 2.0, 3.0] - assert fetched["reverse"].tolist() == [3.0, 2.0, 1.0] + assert fetched["forward"].tolist() == [0.5, 1.0, 1.5] + assert fetched["reverse"].tolist() == [1.5, 1.0, 0.5] assert qn.coverage.calls == [ - {"region": "chr1:100-103", "samples": ["s1"], "strand": "+"}, - {"region": "chr1:100-103", "samples": ["s1"], "strand": "-"}, + { + "region": "chr1:100-103", + "samples": ["s1"], + "as_xarray": False, + "strand": "+", + "normalise": "cpm", + "normalize": None, + "library_sizes": None, + }, + { + "region": "chr1:100-103", + "samples": ["s1"], + "as_xarray": False, + "strand": "-", + "normalise": "cpm", + "normalize": None, + "library_sizes": None, + }, ] def test_methylation_calls_store_with_variable(self):