Skip to main content
Docs

Search guides and API endpoints, for example “Idempotency-Key” or “submit job”.

    Tools · Cheminformatics & Structure

    Dose-Response Curve Analyzer

    Fit raw assay readings to 4PL, 5PL, or Morrison models with replicate QC, and get IC50, EC50, and Ki values ready for curation.

    Updated October 20, 2026

    On this page

    Prices, workflows, and method papersOpen in the app

    The Dose-Response Curve Analyzer fits the raw readings behind an IC50: a plate of doses and responses, with replicates and control wells. Each compound gets a fitted curve, a value on the p-activity scale with a confidence interval, and quality flags. A curve that cannot support a number gets a flag or a bound, never a made-up value.

    How it works

    1. Normalization: with high and low control wells (DMSO / vehicle and a positive control), each plate is scaled to % of the control window and its Z′ is recorded. Plate-median scaling is the alternative for screens without controls; already-normalized data passes through.
    2. Robust fit: response against log₁₀ dose, by bounded nonlinear least squares with a robust loss and deterministic starting values, so the same input always gives the same table.
    3. Outliers: readings whose studentized residual exceeds the threshold (3 by default) are flagged. By default at most one per dose is dropped and the curve refitted; every dropped reading is listed.
    4. Value: the half-effect dose, with a t-based confidence interval.
    ModelFitsUse it for
    4PLtop, bottom, IC50 / EC50, Hill slopeStandard inhibition or activation curves
    5PL4PL plus an asymmetry termCurves steeper on one side than the other
    Morrisontop, bottom, KiTight binders whose IC50 is limited by the enzyme concentration

    With a substrate concentration and Km, a competitive Cheng–Prusoff Ki is reported as Ki = IC50 / (1 + [S]/Km); Morrison applies the same correction to its Ki. Without them, Morrison reports the apparent Ki as Ki_app.

    Quality flags and censoring

    • Too few doses: a curve with fewer distinct doses than the model can support is flagged insufficient_points and not fitted.
    • Bounds, not extrapolation: a curve that never reaches half effect in the tested range is reported as > the top dose (or < the bottom dose), the same relation semantics the Bioactivity Dataset Curator uses. Flat curves are bounds too.
    • Plateaus and slope: poor_top_plateau, poor_bottom_plateau, and hill_out_of_window mark fits that rest on extrapolated plateaus or an implausible Hill slope.
    • Plate effects: edge-well bias is reported per plate, not corrected.

    Inputs

    One long-format table (CSV, TSV, JSON, or an artifact): one row per well with a compound ID or SMILES, a dose, and a response. Replicate, plate, well, control, and assay columns are optional, and common header names (ChEMBL's first) are picked up automatically. Mark control wells as high / low or dmso / pos.

    Outputs

    FileContents
    fits.csvOne row per curve in ChEMBL-style columns (standard_type, standard_relation, standard_value, standard_units), plus p_activity, confidence interval, fitted parameters, flags, and whether the curve was accepted
    qc.csvEvery reading with its role, normalized response, prediction, residual, and status
    curves.csvSampled fitted curves for plotting
    plots/One plot per fitted curve
    summary.jsonNormalization, control statistics, flag counts, and edge-effect checks

    Not covered

    Wide plate-export layouts, vendor instrument formats, and substrate-inhibition kinetics are out of scope. A fitted IC50 reflects one assay's conditions; comparing assays is a curation question.

    fits.csv goes straight into the Bioactivity Dataset Curator & Splitter with no column mapping, and from there into QSAR Models or MPNN Models. For reaction rate constants rather than pharmacology, use the Kinetic Parameter Fitter.

    Run it from the API

    Submit with Submit a job and the job_type below. Price it first with Estimate job reservation cost: submitting reserves that amount from your wallet, and the charge settles at the actual runtime.

    Dose-Response Curve Analyzer dose-response-fit

    Job type
    dose-response-fit
    Hardware
    cpu (default)
    Typical runtime
    2 min on CPU

    Payload

    Payload fields
    FieldTypeDescription
    readingsrequiredstring | object[] | artifactRef
    compound_id_columncolumnName

    Limits: min length 1, max length 64

    smiles_columncolumnName

    Limits: min length 1, max length 64

    dose_columncolumnName

    Limits: min length 1, max length 64

    response_columncolumnName

    Limits: min length 1, max length 64

    replicate_columncolumnName

    Limits: min length 1, max length 64

    plate_columncolumnName

    Limits: min length 1, max length 64

    well_columncolumnName

    Limits: min length 1, max length 64

    control_columncolumnName

    Limits: min length 1, max length 64

    assay_columncolumnName

    Limits: min length 1, max length 64

    dose_unitsstring

    Default: "nM"One of: "pM", "nM", "uM", "mM", "M", "logM"

    endpointstring

    Default: "IC50"One of: "IC50", "EC50"

    directionstring

    Default: "auto"One of: "auto", "decreasing", "increasing"

    modelstring

    Default: "4pl"One of: "4pl", "5pl", "morrison"

    normalizationstring

    Default: "auto"One of: "auto", "none", "controls", "plate_median"

    outlier_policystring

    Default: "drop_and_flag"One of: "drop_and_flag", "flag_only"

    outlier_thresholdnumber

    Default: 3Limits: ≥ 2, ≤ 10

    min_dosesinteger

    Limits: ≥ 4, ≤ 24

    hill_window[]number[]

    Default: [0.3,5]Limits: min items 2, max items 2

    fixed_topnumber
    fixed_bottomnumber
    substrate_concentrationnumber

    Limits: > 0

    kmnumber

    Limits: > 0

    substrate_unitsstring

    Default: "uM"One of: "pM", "nM", "uM", "mM", "M"

    enzyme_concentrationnumber

    Limits: > 0

    enzyme_unitsstring

    Default: "nM"One of: "pM", "nM", "uM", "mM", "M"

    plotsboolean

    Default: true

    Example

    from cognichem_client import CogniChem
    
    client = CogniChem.from_env()  # reads COGNICHEM_API_KEY
    payload = {
        "readings": [
            {
                "compound_id": "DRC-001",
                "smiles": "CC(C)Cc1ccc(cc1)C(C)C(=O)O",
                "concentration": 30000,
                "signal": 1069.3,
                "replicate": 1,
                "plate": "P1",
                "well": "A1",
                "control": "sample",
            },
            {
                "compound_id": "DRC-001",
                "smiles": "CC(C)Cc1ccc(cc1)C(C)C(=O)O",
                "concentration": 10000,
                "signal": 832.5,
                "replicate": 1,
                "plate": "P1",
                "well": "A2",
                "control": "sample",
            },
            {
                "compound_id": "DRC-001",
                "smiles": "CC(C)Cc1ccc(cc1)C(C)C(=O)O",
                "concentration": 3333.3333,
                "signal": 1379.8,
                "replicate": 1,
                "plate": "P1",
                "well": "A3",
                "control": "sample",
            },
            {
                "compound_id": "DRC-001",
                "smiles": "CC(C)Cc1ccc(cc1)C(C)C(=O)O",
                "concentration": 1111.1111,
                "signal": 2254.1,
                "replicate": 1,
                "plate": "P1",
                "well": "A4",
                "control": "sample",
            },
            {
                "compound_id": "DRC-001",
                "smiles": "CC(C)Cc1ccc(cc1)C(C)C(=O)O",
                "concentration": 370.3704,
                "signal": 4996.5,
                "replicate": 1,
                "plate": "P1",
                "well": "A5",
                "control": "sample",
            },
            "… 84 more",
        ],
        "dose_units": "nM",
        "endpoint": "IC50",
        "model": "4pl",
    }
    
    estimate = client.jobs.estimate(job_type="dose-response-fit", payload=payload, resource="cpu")
    print(f"Reserves ${estimate.cost:.2f}")
    
    job = client.jobs.submit(
        job_name="my-dose-response-fit-run",
        job_type="dose-response-fit",
        payload=payload,
        resource="cpu",
    )
    status = client.jobs.wait(job.process_id)
    if status.status == "completed":
        client.jobs.result(job.process_id, save_path=".")

    Sample data from the job catalog; long values are shortened here. Each job_name must be unique among your jobs.

    Workflow inputs

    • Table (list)CSV, TSV, JSON

    Workflow outputs

    • ArchiveZIP
    • TableCSV
    • MoleculesSMILES
    • TableCSV