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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.2.0] - 26-03-2026

### Added

- **Enriched CSV output for post hoc analysis** (`designer/multiplexpanel.py`, `designer/primer.py`): `selected_multiplex.csv`, `top_panels.csv`, and `candidate_pairs.csv` now include 12 additional per-primer and per-amplicon columns: `Forward/Reverse_Self_Any_Th`, `Forward/Reverse_Self_End_Th`, `Forward/Reverse_Hairpin_Th`, `Forward/Reverse_End_Stability`, `Forward/Reverse_Penalty`, `Amplicon_GC`, and `Num_Candidate_Pairs`. These expose design-time thermodynamic features needed to correlate primer properties with wet-lab validation coverage.
- **Per-assay cross-dimer contribution** (`designer/multiplexpanel.py`): `selected_multiplex.csv` and `top_panels.csv` now include a `Cross_Dimer_Contribution` column quantifying the sum of cross-dimer interaction scores between each assay's primers and all other primers in the multiplex. Mirrors the scoring logic in `selector/cost.py` with cached pairwise alignments.

- **Primer-level cross-dimer matrix** (`reporting/qc.py`): `generate_panel_qc()` now computes a full primer-to-primer dimer matrix for the selected multiplex, covering all C(2n, 2) individual primer interactions (including intra-junction pairs). The matrix is returned alongside the existing junction-level summary. Tail sequences (`forward_tail`, `reverse_tail`) are now included in the dimer alignment for realistic scoring. A new `save_primer_dimer_matrix_csv()` function exports the symmetric matrix as a CSV file (`primer_dimer_matrix.csv`), written automatically by the pipeline.
- **Primer-level dimer heatmap in HTML report** (`reporting/templates/panel_report.html.j2`): New interactive Plotly heatmap section showing individual primer-to-primer dimer scores, displayed below the existing junction-level heatmap.

### Fixed

- **Cross-reactivity heatmap not rendering** (`reporting/templates/plotly.min.js.gz`): The bundled Plotly.js was the "basic" partial build which only includes scatter, bar, and pie trace types. The heatmap trace type was missing, causing the cross-reactivity heatmap to silently fail. Replaced with the full Plotly.js v2.35.2 bundle.

- **Target dropout for DFS selector** (`selector/selectors.py`): The DFS selector can now optionally drop targets whose primers cause extreme cross-dimer interactions that "poison" the panel. Enabled via `allow_target_dropping: true` in the multiplex picker config. An adaptive dropout penalty is computed from the greedy seed's marginal cross-dimer costs at a configurable percentile (`dropout_stringency`, default 0.8), so only true outliers are removed. A hard floor prevents excessive dropping: `max(minimum_plexity, ceil(min_target_fraction * n_input))`, clamped to the actual input count.
- **New config fields** (`config.py`, preset JSON files): `allow_target_dropping` (bool, default false), `dropout_stringency` (float 0-1, default 0.8), `min_target_fraction` (float 0-1, default 0.8).
- **Dropped targets in output** (`designer/multiplexpanel.py`): `panel_summary.json` includes a `dropped_targets` list. `top_panels.csv` includes `Num_Dropped` and `Dropped_Targets` columns. Dropped targets are also logged as warnings and surfaced in CLI output.
- **Plexity clamping** (`pipeline.py`): When the number of input targets is less than the configured `maximum_plexity` or `minimum_plexity`, the effective plexity values are clamped to the actual input count, preventing nonsensical constraints.

## [1.1.0] - 13-03-2026

### Changed
Expand Down
24 changes: 12 additions & 12 deletions docker/DOCKERFILE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
ARG BLAST_VERSION=2.17.0
ARG BCFTOOLS_VERSION=1.23
ARG PLEXUS_VERSION=1.0.0
ARG PLEXUS_VERSION=1.2.0

# ── Stage 1: Python venv builder ─────────────────────────────────────────────
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
Expand Down Expand Up @@ -43,18 +43,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \

# Download and extract exact BLAST+ binaries (NCBI prebuilt)
RUN wget -qO /tmp/blast.tar.gz \
"https://ftp.ncbi.nlm.nih.gov/blast/executables/blast+/${BLAST_VERSION}/ncbi-blast-${BLAST_VERSION}+-x64-linux.tar.gz" \
"https://ftp.ncbi.nlm.nih.gov/blast/executables/blast+/${BLAST_VERSION}/ncbi-blast-${BLAST_VERSION}+-x64-linux.tar.gz" \
&& tar -xzf /tmp/blast.tar.gz -C /tmp \
&& mkdir -p /tools/bin \
&& cp /tmp/ncbi-blast-${BLAST_VERSION}+/bin/blastn \
/tmp/ncbi-blast-${BLAST_VERSION}+/bin/makeblastdb \
/tmp/ncbi-blast-${BLAST_VERSION}+/bin/blast_formatter \
/tools/bin/ \
/tmp/ncbi-blast-${BLAST_VERSION}+/bin/makeblastdb \
/tmp/ncbi-blast-${BLAST_VERSION}+/bin/blast_formatter \
/tools/bin/ \
&& rm -rf /tmp/blast.tar.gz /tmp/ncbi-blast-${BLAST_VERSION}+

# Compile exact bcftools from source
RUN wget -qO /tmp/bcftools.tar.bz2 \
"https://github.com/samtools/bcftools/releases/download/${BCFTOOLS_VERSION}/bcftools-${BCFTOOLS_VERSION}.tar.bz2" \
"https://github.com/samtools/bcftools/releases/download/${BCFTOOLS_VERSION}/bcftools-${BCFTOOLS_VERSION}.tar.bz2" \
&& tar -xjf /tmp/bcftools.tar.bz2 -C /tmp \
&& cd /tmp/bcftools-${BCFTOOLS_VERSION} \
&& make -j$(nproc) \
Expand All @@ -70,12 +70,12 @@ ARG PLEXUS_VERSION

# Bake version and audit metadata into the image
LABEL org.opencontainers.image.title="plexus" \
org.opencontainers.image.version="${PLEXUS_VERSION}" \
org.opencontainers.image.description="Multiplex PCR primer panel designer" \
org.opencontainers.image.licenses="GPL-2.0-or-later" \
dev.plexus.blast_version="${BLAST_VERSION}" \
dev.plexus.bcftools_version="${BCFTOOLS_VERSION}" \
dev.plexus.compliance_mode="true"
org.opencontainers.image.version="${PLEXUS_VERSION}" \
org.opencontainers.image.description="Multiplex PCR primer panel designer" \
org.opencontainers.image.licenses="GPL-2.0-or-later" \
dev.plexus.blast_version="${BLAST_VERSION}" \
dev.plexus.bcftools_version="${BCFTOOLS_VERSION}" \
dev.plexus.compliance_mode="true"

# Non-root runtime user
RUN useradd --system --create-home --shell /bin/bash plexus
Expand Down
25 changes: 25 additions & 0 deletions src/plexus/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,31 @@ class MultiplexPickerParameters(BaseModel):
plexity_wt_gt: float = Field(default=1.0, ge=0.0)

force_plexity: bool = Field(default=False)

allow_target_dropping: bool = Field(
default=False,
description="Allow the DFS selector to drop targets with extreme cross-dimer toxicity.",
)
dropout_stringency: float = Field(
default=0.8,
ge=0.0,
le=1.0,
description=(
"Percentile (0-1) of marginal cross-dimer distribution used to set "
"the dropout penalty. Higher values = stricter = fewer drops. "
"0.8 means only targets worse than the 80th percentile are drop candidates."
),
)
min_target_fraction: float = Field(
default=0.8,
ge=0.0,
le=1.0,
description=(
"Minimum fraction of input targets to retain. "
"Effective floor = max(minimum_plexity, ceil(min_target_fraction * n_input))."
),
)

allow_split_panel: bool = Field(default=False)
max_splits: int = Field(default=2, ge=1, le=10)

Expand Down
3 changes: 3 additions & 0 deletions src/plexus/data/designer_default_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@
"plexity_wt_lt": 1.0,
"plexity_wt_gt": 1.0,
"force_plexity": false,
"allow_target_dropping": false,
"dropout_stringency": 0.8,
"min_target_fraction": 0.8,
"allow_split_panel": false,
"max_splits": 2,
"wt_pair_penalty": 1.0,
Expand Down
3 changes: 3 additions & 0 deletions src/plexus/data/designer_lenient_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@
"plexity_wt_lt": 1.0,
"plexity_wt_gt": 1.0,
"force_plexity": false,
"allow_target_dropping": false,
"dropout_stringency": 0.8,
"min_target_fraction": 0.8,
"allow_split_panel": false,
"max_splits": 2,
"wt_pair_penalty": 1.0,
Expand Down
98 changes: 97 additions & 1 deletion src/plexus/designer/multiplexpanel.py
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,20 @@ def save_candidate_pairs_to_csv(self, file_path: str):
"Off_Target_Count": len(pair.off_target_products),
"Specificity_Checked": pair.specificity_checked,
"Selected": pair.selected,
"Forward_Self_Any_Th": pair.forward.self_any_th,
"Reverse_Self_Any_Th": pair.reverse.self_any_th,
"Forward_Self_End_Th": pair.forward.self_end_th,
"Reverse_Self_End_Th": pair.reverse.self_end_th,
"Forward_Hairpin_Th": pair.forward.hairpin_th,
"Reverse_Hairpin_Th": pair.reverse.hairpin_th,
"Forward_End_Stability": pair.forward.end_stability,
"Reverse_End_Stability": pair.reverse.end_stability,
"Forward_Penalty": pair.forward.penalty,
"Reverse_Penalty": pair.reverse.penalty,
"Amplicon_GC": pair.amplicon_gc,
"Num_Candidate_Pairs": (
len(junction.primer_pairs) if junction.primer_pairs else 0
),
}
data.append(row)

Expand Down Expand Up @@ -901,6 +915,20 @@ def _build_enriched_pair_row(self, junction: Junction, pair: PrimerPair) -> dict
"SNP_Penalty": pair.snp_penalty,
"Forward_SNP_Count": pair.forward.snp_count,
"Reverse_SNP_Count": pair.reverse.snp_count,
"Forward_Self_Any_Th": pair.forward.self_any_th,
"Reverse_Self_Any_Th": pair.reverse.self_any_th,
"Forward_Self_End_Th": pair.forward.self_end_th,
"Reverse_Self_End_Th": pair.reverse.self_end_th,
"Forward_Hairpin_Th": pair.forward.hairpin_th,
"Reverse_Hairpin_Th": pair.reverse.hairpin_th,
"Forward_End_Stability": pair.forward.end_stability,
"Reverse_End_Stability": pair.reverse.end_stability,
"Forward_Penalty": pair.forward.penalty,
"Reverse_Penalty": pair.reverse.penalty,
"Amplicon_GC": pair.amplicon_gc,
"Num_Candidate_Pairs": (
len(junction.primer_pairs) if junction.primer_pairs else 0
),
}

def _junction_for_pair(self, pair: PrimerPair) -> Junction | None:
Expand All @@ -924,13 +952,41 @@ def save_selected_multiplex_csv(self, file_path: str, selected_pairs: list) -> N
logger.warning("No selected pairs to save.")
return

# Pre-compute cross-dimer scores for per-pair attribution
dimer_predictor = PrimerDimerPredictor()
dimer_cache: dict[tuple[str, str], float] = {}
all_primers: dict[str, list[tuple[str, str]]] = {}
for pair in selected_pairs:
all_primers[pair.pair_id] = [
(pair.forward.seq, pair.forward.name),
(pair.reverse.seq, pair.reverse.name),
]

data = []
for pair in selected_pairs:
junction = self._junction_for_pair(pair)
if junction is None:
logger.warning(f"Could not find junction for pair {pair.pair_id}")
continue
data.append(self._build_enriched_pair_row(junction, pair))
row = self._build_enriched_pair_row(junction, pair)

contribution = 0.0
for other in selected_pairs:
if other.pair_id == pair.pair_id:
continue
for seq_a, name_a in all_primers[pair.pair_id]:
for seq_b, name_b in all_primers[other.pair_id]:
key = tuple(sorted((seq_a, seq_b)))
if key not in dimer_cache:
dimer_predictor.set_primers(seq_a, seq_b, name_a, name_b)
dimer_predictor.align()
dimer_cache[key] = dimer_predictor.score or 0.0
score = dimer_cache[key]
if score < 0:
contribution += abs(score)
row["Cross_Dimer_Contribution"] = round(contribution, 4)

data.append(row)

df = pd.DataFrame(data)
df.to_csv(file_path, index=False)
Expand All @@ -948,19 +1004,56 @@ def save_top_panels_csv(self, file_path: str, solutions: list) -> None:
return

pair_lookup = self.build_pair_lookup()
dimer_predictor = PrimerDimerPredictor()
dimer_cache: dict[tuple[str, str], float] = {}
data = []

for rank, solution in enumerate(solutions, start=1):
# Collect primers for this solution's pairs
sol_primers: dict[str, list[tuple[str, str]]] = {}
sol_pairs: list[PrimerPair] = []
for pair_id in solution.primer_pairs:
pair = pair_lookup.get(pair_id)
if pair is None:
continue
sol_pairs.append(pair)
sol_primers[pair_id] = [
(pair.forward.seq, pair.forward.name),
(pair.reverse.seq, pair.reverse.name),
]

for pair in sol_pairs:
junction = self._junction_for_pair(pair)
if junction is None:
continue
row = self._build_enriched_pair_row(junction, pair)

contribution = 0.0
for other in sol_pairs:
if other.pair_id == pair.pair_id:
continue
for seq_a, name_a in sol_primers[pair.pair_id]:
for seq_b, name_b in sol_primers[other.pair_id]:
key = tuple(sorted((seq_a, seq_b)))
if key not in dimer_cache:
dimer_predictor.set_primers(
seq_a, seq_b, name_a, name_b
)
dimer_predictor.align()
dimer_cache[key] = dimer_predictor.score or 0.0
score = dimer_cache[key]
if score < 0:
contribution += abs(score)
row["Cross_Dimer_Contribution"] = round(contribution, 4)

row["Solution_Rank"] = rank
row["Solution_Cost"] = round(solution.cost, 4)
row["Num_Dropped"] = len(solution.dropped_targets)
row["Dropped_Targets"] = (
"; ".join(solution.dropped_targets)
if solution.dropped_targets
else ""
)
data.append(row)

df = pd.DataFrame(data)
Expand Down Expand Up @@ -1054,6 +1147,9 @@ def save_panel_summary_json(
"num_junctions": len(self.junctions),
"num_candidate_pairs": total_pairs,
"num_selected_pairs": len(pipeline_result.selected_pairs),
"dropped_targets": pipeline_result.multiplex_solutions[0].dropped_targets
if pipeline_result.multiplex_solutions
else [],
"best_multiplex_cost": round(pipeline_result.multiplex_solutions[0].cost, 4)
if pipeline_result.multiplex_solutions
else None,
Expand Down
9 changes: 9 additions & 0 deletions src/plexus/designer/primer.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ class PrimerPair:
snp_count: int = 0
snp_penalty: float = 0.0

@property
def amplicon_gc(self) -> float | None:
"""GC content of the amplicon sequence as a percentage."""
seq = self.amplicon_sequence
if not seq:
return None
gc_count = seq.upper().count("G") + seq.upper().count("C")
return round(100.0 * gc_count / len(seq), 2)

@staticmethod
def calculate_primer_pair_penalty_th(
primer_left_penalty,
Expand Down
Loading
Loading