Source code for tooluniverse.drug_synergy_tool

"""
Drug Synergy Computation Tool

Implements standard drug synergy models from peer-reviewed literature:
- Bliss Independence (1939)
- Highest Single Agent (HSA)
- ZIP (Zero Interaction Potency)

No external API calls. Uses numpy/scipy for computation.
"""

from typing import Dict, Any
from .base_tool import BaseTool
from .tool_registry import register_tool

try:
    import numpy as np

    HAS_NUMPY = True
except ImportError:
    HAS_NUMPY = False


[docs] @register_tool("DrugSynergyTool") class DrugSynergyTool(BaseTool): """ Local drug combination synergy analysis tools. Implements standard pharmacological synergy models: - Bliss Independence model - Highest Single Agent (HSA) model - ZIP (Zero Interaction Potency) model No external API required. Uses numpy for computation. """
[docs] def __init__(self, tool_config: Dict[str, Any]): super().__init__(tool_config) self.parameter = tool_config.get("parameter", {}) self.required = self.parameter.get("required", [])
[docs] def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]: if not HAS_NUMPY: return { "status": "error", "error": "numpy is required for drug synergy calculations. Install with: pip install numpy", } operation = arguments.get("operation") if not operation: return {"status": "error", "error": "Missing required parameter: operation"} operation_handlers = { "calculate_bliss": self._calculate_bliss, "calculate_hsa": self._calculate_hsa, "calculate_zip": self._calculate_zip, } handler = operation_handlers.get(operation) if not handler: return { "status": "error", "error": f"Unknown operation: {operation}", "available_operations": list(operation_handlers.keys()), } try: return handler(arguments) except Exception as e: return {"status": "error", "error": f"Calculation failed: {str(e)}"}
[docs] def _interpret_synergy_score(self, score: float, model: str) -> str: if model in ("bliss", "hsa"): if score > 10: return "Strong synergy" elif score > 0: return "Synergy" elif score == 0: return "Additivity" elif score > -10: return "Antagonism" else: return "Strong antagonism" else: if score > 10: return "Synergy" elif score > -10: return "Additive" else: return "Antagonism"
[docs] def _calculate_bliss(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """ Calculate Bliss Independence synergy score. Bliss model: E_expected = E_a + E_b - E_a * E_b Synergy score = E_combination - E_expected Positive score = synergy; Negative = antagonism. Effects should be expressed as fractional inhibition (0-1). """ effect_a = arguments.get("effect_a") effect_b = arguments.get("effect_b") effect_combination = arguments.get("effect_combination") if effect_a is None or effect_b is None or effect_combination is None: return { "status": "error", "error": "effect_a, effect_b, and effect_combination are all required", } try: ea = float(effect_a) eb = float(effect_b) ec = float(effect_combination) except (ValueError, TypeError) as e: return {"status": "error", "error": f"Invalid numeric values: {e}"} # Validate range for name, val in [ ("effect_a", ea), ("effect_b", eb), ("effect_combination", ec), ]: if not (0 <= val <= 1): return { "status": "error", "error": f"{name}={val} must be between 0 and 1 (fractional inhibition)", } expected = ea + eb - ea * eb synergy_score = (ec - expected) * 100 # Express as percentage points return { "status": "success", "data": { "model": "Bliss Independence", "effect_a": ea, "effect_b": eb, "effect_combination_observed": ec, "effect_combination_expected": round(expected, 4), "bliss_synergy_score": round(synergy_score, 2), "interpretation": self._interpret_synergy_score(synergy_score, "bliss"), "note": "Positive score = synergy; Negative = antagonism. Based on Bliss (1939).", }, }
[docs] def _calculate_hsa(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """ Calculate Highest Single Agent (HSA) synergy score. HSA model: E_expected = max(E_a, E_b) at each dose point Synergy = E_combination - max single agent effect. """ effects_a = arguments.get("effects_a", []) effects_b = arguments.get("effects_b", []) effects_combo = arguments.get("effects_combo", []) if not effects_a or not effects_b or not effects_combo: return { "status": "error", "error": "effects_a, effects_b, and effects_combo are all required", } try: ea = np.array([float(x) for x in effects_a]) eb = np.array([float(x) for x in effects_b]) ec = np.array([float(x) for x in effects_combo]) except (ValueError, TypeError) as e: return {"status": "error", "error": f"Invalid numeric values: {e}"} if len(ea) != len(ec) or len(eb) != len(ec): return { "status": "error", "error": "effects_a, effects_b, and effects_combo must have the same length", } hsa = np.maximum(ea, eb) synergy_matrix = (ec - hsa) * 100 # percentage points return { "status": "success", "data": { "model": "Highest Single Agent (HSA)", "mean_hsa_synergy_score": round(float(np.mean(synergy_matrix)), 2), "max_hsa_synergy_score": round(float(np.max(synergy_matrix)), 2), "min_hsa_synergy_score": round(float(np.min(synergy_matrix)), 2), "synergy_scores_per_point": [ round(float(s), 2) for s in synergy_matrix ], "hsa_expected": [round(float(h), 4) for h in hsa], "interpretation": self._interpret_synergy_score( float(np.mean(synergy_matrix)), "hsa" ), "note": "Positive score = synergy over best single agent; Negative = antagonism.", }, }
[docs] def _calculate_zip(self, arguments: Dict[str, Any]) -> Dict[str, Any]: """ Calculate ZIP (Zero Interaction Potency) synergy score. ZIP model uses dose-response curves to calculate delta scores. Input: doses_a, doses_b (1D arrays), viability_matrix (2D). Output: ZIP delta synergy score. """ doses_a = arguments.get("doses_a", []) doses_b = arguments.get("doses_b", []) viability_matrix = arguments.get("viability_matrix", []) if not doses_a or not doses_b or not viability_matrix: return { "status": "error", "error": "doses_a, doses_b, and viability_matrix are all required", } try: da = np.array([float(x) for x in doses_a]) db = np.array([float(x) for x in doses_b]) vm = np.array([[float(x) for x in row] for row in viability_matrix]) except (ValueError, TypeError) as e: return {"status": "error", "error": f"Invalid numeric values: {e}"} if vm.shape != (len(da), len(db)): return { "status": "error", "error": f"viability_matrix shape {vm.shape} must be ({len(da)}, {len(db)})", } # Inhibition matrix (1 - viability) inhibition = 1 - vm / 100 if vm.max() > 1 else 1 - vm # Fit simple Hill curves for each drug from scipy.optimize import curve_fit def hill_curve(x, ic50, hill, emax): x = np.maximum(x, 1e-12) return emax * x**hill / (ic50**hill + x**hill) def fit_hill(doses, effects): try: valid = doses > 0 d, e = doses[valid], effects[valid] if len(d) < 3: return None p0 = [np.median(d), 1.0, max(e)] bounds = ([0, 0.1, 0], [np.inf, 10, 1]) popt, _ = curve_fit(hill_curve, d, e, p0=p0, bounds=bounds, maxfev=5000) return popt except Exception: return None # Get marginal effects effects_a_marginal = ( inhibition[:, 0] if inhibition.shape[1] > 0 else inhibition.mean(axis=1) ) effects_b_marginal = ( inhibition[0, :] if inhibition.shape[0] > 0 else inhibition.mean(axis=0) ) params_a = fit_hill(da, effects_a_marginal) params_b = fit_hill(db, effects_b_marginal) if params_a is None or params_b is None: # Fallback: simplified ZIP using means expected_zip = ( np.outer(effects_a_marginal, np.ones(len(db))) + np.outer(np.ones(len(da)), effects_b_marginal) - np.outer(effects_a_marginal, effects_b_marginal) ) else: # ZIP expected using Hill fits pred_a = np.array([hill_curve(d, *params_a) for d in da]) pred_b = np.array([hill_curve(d, *params_b) for d in db]) expected_zip = ( np.outer(pred_a, np.ones(len(db))) + np.outer(np.ones(len(da)), pred_b) - np.outer(pred_a, pred_b) ) delta = (inhibition - expected_zip) * 100 return { "status": "success", "data": { "model": "ZIP (Zero Interaction Potency)", "mean_zip_score": round(float(np.mean(delta)), 2), "max_zip_score": round(float(np.max(delta)), 2), "min_zip_score": round(float(np.min(delta)), 2), "zip_delta_matrix": [ [round(float(v), 2) for v in row] for row in delta ], "interpretation": self._interpret_synergy_score( float(np.mean(delta)), "zip" ), "note": "ZIP delta > 10: synergy; < -10: antagonism. Based on Yadav et al. (2015).", }, }