spacr.qt.widgets.dose_response

Dose–response — a four-parameter logistic that will refuse to name an EC50.

spaCR runs concentration series and has, until this module, had no curve fitter at all: a screener with eight doses and three replicates exports a CSV and opens Prism. The point of writing one is not that “it fits” — every optimiser fits — but that the answer is often “you cannot tell from this experiment”, and no fitter in common use says so. curve_fit will return four numbers and a covariance matrix for a dilution series that never reached a plateau, for a bell-shaped cytotoxicity curve, and for eight points of pure noise. All three come back looking like an EC50 with a confidence interval.

So the decisions here are almost all about refusing, and each one is written down.

Why log10(EC50) is the parameter, and EC50 is derived

The model is fitted in log10_ec50, never in EC50:

\[y = \mathrm{bottom} + \frac{\mathrm{top} - \mathrm{bottom}} {1 + 10^{(\log_{10}\mathrm{EC}_{50} - \log_{10}x)\,h}}\]

Three reasons, all of which show up in real data:

  • The doses are geometric. A 2- or 3-fold dilution series is evenly spaced in log concentration and violently uneven in linear concentration. The information the experiment carries about the midpoint is information about which dilution step it sits on, which is a statement in log space.

  • The likelihood surface is close to symmetric in log10(EC50) and badly skewed in EC50. That is what makes a quadratic (Wald) approximation defensible on log10_ec50 and indefensible on EC50.

  • A linear-space interval routinely reaches below zero, and a negative concentration is not a thing. An EC50 of 1.0 ± 1.4 µM is not a wide interval, it is a broken one.

Reporting therefore back-transforms the interval: 10 ** [lo, hi]. The result is multiplicative (0.62 – 1.61 µM around 1.0, a 1.6-fold factor either way), always positive, and reads the way a potency actually behaves.

Which way the curve goes

The parameterisation has an exact symmetry: (bottom, top, L, h) and (top, bottom, L, -h) are the same function, so the optimiser may return either. Left alone, two runs on the same data can report Hill slopes of opposite sign. Every fit is therefore canonicalised the way pharmacology already writes it (and GraphPad’s variable-slope equations do):

  • top >= bottom always — they are the larger and smaller plateau, not the right-hand and left-hand one;

  • the sign of the Hill slope carries the direction: hill < 0 is inhibition (response falls as dose rises, and top is the low-dose control plateau), hill > 0 is activation.

The direction is inferred from the data (Spearman rho of response against log dose) and never assumed, so an activation series is not fitted upside down. DIRECTION_INHIBITION / DIRECTION_ACTIVATION pin it when the user knows better than the correlation does.

Two confidence intervals, and why the default is the slow one

Both are offered on log10_ec50 and both back-transform:

  • CI_WALD — asymptotic, from the covariance matrix curve_fit returns, with a t quantile on n - 4 degrees of freedom. Not a normal quantile: eight concentrations with no replicates leaves 4 df, where t = 2.776 against z = 1.960. That is a 42% difference in the width of the published interval, which is not cosmetic.

  • CI_PROFILE — profile likelihood, the default. For a grid of candidate log10_ec50 values the other three parameters are re-optimised and the residual sum of squares is compared against SSE_min * (1 + t²/(n - 4)), the standard F-based profile region for one parameter out of four. (F(1, ν) = t(ν)², so the two intervals use literally the same quantile and differ only in the shape of the surface they walk.)

The profile is the default for one reason that outweighs its cost: the Wald interval is finite by construction. It is L ± t·SE, so it returns a tidy, symmetric, entirely fictional interval for a curve whose midpoint the data does not locate at all — which is exactly the case this module exists to catch. The profile interval can fail to close: the residual sum of squares stays under the threshold all the way out to a concentration ten thousand times the highest dose tested, and that failure is the diagnostic. It is reported as an open bound, not smoothed into a number.

A residual bootstrap was the other candidate and was rejected. It shares the profile’s homoscedastic-normal assumption so it buys no robustness; it needs a seed and gives a slightly different published number per seed; and — the deciding objection — percentiles of a bootstrap distribution are always finite, so it launders an unidentified parameter into a confident interval in precisely the situation that matters. Nothing here resamples and nothing here is random: the same data gives the same interval, every time.

bottom, top and the Hill slope get Wald intervals only. Profiling each of them would quadruple the work to improve numbers nobody quotes; the EC50 is the number that leaves the building.

The three things that actually go wrong

  1. The curve is incomplete — one plateau was never reached, or the midpoint sits outside the tested range. Then the EC50 is an extrapolation. DoseResponseResult.ec50 is None in that case and the number lives in DoseResponseResult.ec50_unconstrained, under a name that cannot be mistaken for a result; DoseResponseResult.ec50_bounded is False and DoseResponseResult.bound_statement() gives the one-sided fact the experiment does support (“EC50 > 30 µM, the highest concentration tested”). Three independent detectors have to agree it is fine before a number is released: the fitted midpoint inside the tested dose range, the observed responses bracketing the fitted half-maximum, and neither fitted plateau more than PLATEAU_SLACK of the observed response span outside the observed responses.

  2. The data is not monotone. A bell shape — the classic being cytotoxicity killing the signal at the top dose — is not a 4PL, and a 4PL fitted to it returns a confident EC50 for a curve of the wrong shape. Detected from the concentration-ordered per-dose medians and refused, with the concentrations where it turns named in the message. See monotonicity().

  3. There is not enough experiment. Four parameters need at least four distinct concentrations (MIN_DOSES) and, to say anything about uncertainty, more observations than parameters (MIN_OBSERVATIONS). A constant response has no curve in it. A zero concentration is not an error — a vehicle control is normal and belongs in the file — so it is excluded from the fit deliberately, counted, and reported as a reference response, never fed to log10. A negative concentration has no such reading and is refused.

Two models, and why the second one is offered rather than chosen

MODEL_5PL fits five_parameter_logistic(), a 4PL with one more parameter that lets the curve approach its two plateaus at different rates. Real dose–responses are asymmetric — receptor occupancy with cooperativity, anything read through a saturating stain — and a 4PL fitted to an asymmetric curve does not fail, it moves the EC50: on the calibration series in tests/qt/test_dose_response_fits_an_asymmetric_curve.py a symmetric fit to a curve whose true EC50 is 1.0 reports 0.67, with a tight interval and an R² of 0.997.

It is nonetheless not the default, and that is a decision rather than an oversight. The fifth parameter trades off against the Hill slope — many (slope, asymmetry) pairs draw nearly the same curve — so on the eight or ten concentrations a plate actually carries it is weakly identified, and the profile interval it produces is correspondingly wider. The module therefore offers it, fits it from the 4PL solution so the asymmetric search starts at the symmetric answer, and reports an extra-sum-of-squares F test of the one added parameter in DoseResponseResult.asymmetry_p so a reader can see whether the asymmetry was worth what it cost.

The parameterisation is the EC50-preserving one: log10_ec50 means the same thing under both models, so every rule in this file — boundedness, profiling, the back-transformed reporting — reads one number and not two. five_parameter_logistic() explains why that needs a constant in the denominator and what goes wrong without it.

Hormesis, which is the other reason a curve turns around

monotonicity() can see that a series reverses. It cannot say which reversal it is, and the two mean opposite things:

  • the response collapses at the top dose — usually the compound killing whatever was being counted. The advice is to drop that dose;

  • the response rises at the bottom dose before falling — hormesis. The advice is emphatically not to drop those doses, because the rise is the finding.

hormesis() tells them apart by fitting brain_cousens() — the 4PL with a low-dose stimulation term, which is the 4PL exactly when that term is zero — and comparing the two nested fits by an extra-sum-of-squares F test (the likelihood-ratio test, under this module’s Gaussian errors), a small-sample-corrected AIC, and a minimum-effect threshold. All three, plus a positive coefficient, or the answer is no. The threshold is there because a tight assay will reach p < 0.001 on a hump worth 2% of the response span, and 2% is not a biological claim.

The test runs on any series whose low-dose end departs from control against the trend, not only on the ones monotonicity() refuses — a hump can carry a fifth of the response span and still sit inside MAX_REVERSAL, in which case today’s fit succeeds, quotes an EC50 displaced by the hump, and says nothing. Those fits now carry the finding in DoseResponseResult.hormesis and in their caveats.

Fit quality, and the reason R² is printed with a warning attached

Every result carries the residual standard error, R², and — when the design has replicates — a lack-of-fit F test against pure error. That last one is the statistic that answers the question people think R² answers.

R² on a sigmoid is nearly useless. Any monotone curve through a well-sampled dose–response scores above 0.95, because the total sum of squares is dominated by the difference between the two plateaus and any S-shaped line captures that. DoseResponseResult.caveats() says so next to the number, every time.

The lack-of-fit test does the real work when replicates exist: pure error (within-concentration scatter, n - m df) is a model-free estimate of noise, and the residual variance in excess of it (m - 4 df) is model-misspecification. A small p-value means a 4PL is the wrong shape for this data whatever the R² says. With no replicates there is no pure-error estimate and the test does not exist — which is itself reported, because “cannot be tested” and “passed” are different states.

No Qt in here

numpy, pandas and scipy only, like spacr.qt.widgets.pca_model and spacr.selection: usable from a notebook, testable without a display, and with nothing in the fitting path that knows a widget exists. There is not a single PySide6 import in this file, and the two seams that would need one — candidate_concentration_columns() and candidate_response_columns(), which re-use the Local Data Filter’s column classifier through spacr.qt.widgets.graph_spec.column_kinds() rather than inventing a second one — do it inside the function body, so importing this module and fitting a curve pulls in no Qt of its own.

(spacr/qt/widgets/__init__.py eagerly imports the widget modules, so reaching this module through the package still costs a PySide6 import today. That is a property of the package’s __init__, not of this file: nothing here would have to change for the fitter to run in an environment without PySide6.)

Exceptions

DoseResponseError

A dose–response that cannot mean anything, with the way out in the text.

Classes

Checkerboard

A two-agent dose grid pulled out of a well table, with its two axes.

DoseResponseResult

One fitted curve, plus everything needed to know whether to quote it.

DoseResponseSet

Every curve on a plate, in the order the levels were seen.

DoseResponseSpec

Which columns to fit and under what policy.

GroupFit

One level of the grouping column: a result, or the refusal.

HormesisCheck

Whether the low-dose end of the series is stimulated, and by how much.

InteractionSurface

A checkerboard's interaction, per cell, with its own axes.

MonotonicityCheck

Whether the concentration-ordered response only ever goes one way.

PlateReport

What one plate's controls said, and whether the plate may be fitted.

PlateSpec

Which column is the plate, and which wells on it are the controls.

PooledFit

One EC50 across replicate plates, with plate as a random effect.

SelectivityIndex

Host toxicity over parasite killing, with the interval it deserves.

Functions

bliss_surface(→ InteractionSurface)

Bliss independence over a checkerboard.

brain_cousens(x, bottom, top, log10_ec50, hill, ...)

The Brain–Cousens hormesis model: a 4PL with a low-dose hump on it.

candidate_concentration_columns(→ Tuple[str, ...])

Columns worth offering as the concentration axis.

candidate_response_columns(→ Tuple[str, ...])

Columns worth offering as the response axis: every continuous one.

checkerboard_from_frame(→ Checkerboard)

Read a checkerboard off a well table, single-agent axes and all.

fit_dose_response(→ DoseResponseResult)

Fit one concentration series, or refuse it.

fit_frame(→ DoseResponseSet)

Fit one curve per level of spec.group — the whole plate at once.

five_parameter_logistic(x, bottom, top, log10_ec50, ...)

The 5PL curve, parameterised so log10_ec50 is still the EC50.

four_parameter_logistic(x, bottom, top, log10_ec50, hill)

The 4PL curve, parameterised in log10(EC50).

hormesis(→ HormesisCheck)

Is the low-dose end of this series stimulated, or is it noise?

loewe_surface(→ InteractionSurface)

Loewe additivity over a checkerboard, as a combination index.

monotonicity(→ MonotonicityCheck)

Is this concentration series consistent with a single sigmoid?

normalise_to_controls(→ Tuple[pandas.DataFrame, ...)

Percent inhibition against each plate's own controls.

plate_reports(→ Tuple[PlateReport, ...])

The per-plate verdict alone, without normalising anything.

pool_across_plates(→ PooledFit)

Combine per-plate fits into one EC50 with plate as a random effect.

pool_frame(→ PooledFit)

Fit each plate in frame on its own, then pool them.

selectivity_index(→ SelectivityIndex)

Divide a host EC50 by a parasite EC50, and carry the uncertainty.

Module Contents

exception spacr.qt.widgets.dose_response.DoseResponseError[source]

Bases: ValueError

A dose–response that cannot mean anything, with the way out in the text.

Raised rather than returned as an empty result, for the same reason spacr.qt.widgets.pca_model.PCAError is: every one of these is a sentence a screener can act on — “the response turns around between 10 and 30 µM, which is usually cytotoxicity; drop the top dose” — and a caller that swallowed it would draw an empty axis with no explanation.

fit_frame() catches it per group and keeps the message beside that group’s row, so one bad compound does not take the plate down.

Initialize self. See help(type(self)) for accurate signature.

class spacr.qt.widgets.dose_response.Checkerboard[source]

A two-agent dose grid pulled out of a well table, with its two axes.

The three surface functions take parallel arrays; a plate reader hands you a table. This is the join between them, and it keeps the single-agent rows – the row where B is zero and the column where A is zero – because bliss_surface() and loewe_surface() are both calibrated against those axes and a caller who filtered them out would silently get a surface with no reference.

Parameters:
  • dose_a – agent A concentration per well, combination wells included.

  • dose_b – agent B concentration per well.

  • response – the measured response per well.

  • a_alone – (dose, response) for the wells where B is zero.

  • b_alone – (dose, response) for the wells where A is zero.

property shape: Tuple[int, int][source]

How many distinct A doses by how many distinct B doses.

class spacr.qt.widgets.dose_response.DoseResponseResult[source]

One fitted curve, plus everything needed to know whether to quote it.

The important field is ec50_bounded. When it is False, ec50 is None — there is no way to read a point estimate out of this object without passing the flag, which is the whole design. The fitted number is still there, under ec50_unconstrained, because hiding it would only make people re-derive it; the name says what it is.

Parameters:
  • group – the level this curve belongs to, or "".

  • bottom – smaller plateau.

  • top – larger plateau.

  • log10_ec50 – the fitted parameter. Always finite when the fit converged, whether or not it is inside the tested range.

  • hill – slope; negative for inhibition, positive for activation.

  • ec50 – the quotable half-maximal concentration, or None when the experiment does not bound it.

  • ec50_unconstrained – 10 ** log10_ec50, always. An extrapolation when ec50_bounded is False.

  • ec50_bounded – whether the data bound the EC50: it lies inside the tested range, the curve’s midpoint is within the observed responses, both plateaus are reached and the interval is closed on both sides. ec50 is None unless it is True.

  • ec50_low – back-transformed lower end of the interval, or None for an open side.

  • ec50_high – back-transformed upper end of the interval, or None for an open side.

  • log10_ec50_ci – the same interval before back-transformation.

  • hill_ci – Wald interval for the Hill slope.

  • top_ci – Wald interval for the top asymptote.

  • bottom_ci – Wald interval for the bottom asymptote.

  • bound_direction – one of BOUND_OK, BOUND_ABOVE, BOUND_BELOW, BOUND_OPEN.

  • dose – the concentrations actually fitted, in input order.

  • response – the responses actually fitted, aligned with dose (positive concentrations only), in input order.

  • n_obs – observations fitted.

  • n_doses – distinct concentrations.

  • dof – residual degrees of freedom — n_obs less the model’s parameter count, so n_obs - 4 for a 4PL and n_obs - 5 for a 5PL.

  • dose_min – lowest distinct positive concentration fitted.

  • dose_max – highest distinct positive concentration fitted.

  • sse – residual sum of squares of the fitted curve.

  • rse – residual standard error, in response units.

  • r_squared – with the health warning in caveats() attached.

  • lack_of_fit_f – F statistic of the test against pure error.

  • lack_of_fit_p – p value of the test against pure error, or None when the design cannot support it.

  • lack_of_fit_df – (numerator, denominator) df of that test.

  • covariance – the 4×4 matrix, or None when it could not be estimated.

  • covariance_ok – whether it is finite and usable.

  • optimizer_notes – every warning scipy raised during the fit, captured rather than allowed to escape — an OptimizeWarning: Covariance of the parameters could not be estimated is a result, not console noise.

  • vehicle_response – mean response at concentration 0, or None.

  • n_vehicle – how many vehicle observations there were.

  • n_excluded – rows dropped for a missing or non-finite value.

  • check – the MonotonicityCheck this fit passed (or was forced past).

  • model – MODEL_4PL or MODEL_5PL — which curve these parameters belong to.

  • asymmetry – the 5PL exponent. Exactly 1.0 under MODEL_4PL, where it is not a parameter at all.

  • asymmetry_ci – its Wald interval, or (None, None).

  • asymmetry_f – extra-sum-of-squares F of the 5PL against the 4PL on the same points, or None under MODEL_4PL.

  • asymmetry_p – its p value. A large p is the interesting case: it says the asymmetry the 5PL fitted is inside what the noise would produce, and the symmetric fit is the one to quote.

  • hormesis – the HormesisCheck when the low-dose end of the series was worth testing, else None. A fit can be perfectly monotone by monotonicity() and still carry a hormetic hump that displaces its EC50, so this is reported beside a successful fit and not only inside a refusal.

bound_statement() → str[source]

The one-sided fact the experiment supports, when it supports no two-sided one.

Returns "" for a bounded fit, so a caller can print it unconditionally.

caveats() → Tuple[str, ...][source]

Everything a reader needs before believing the number.

curve(points: int = 200) → Tuple[numpy.ndarray, numpy.ndarray][source]

(x, y) for drawing, geometrically spaced across the tested range with half a decade of margin at each end.

Geometric because the axis a dose–response is read on is logarithmic; an evenly spaced grid would put nine tenths of its points in the top dilution and draw the interesting part as three line segments.

curve_frame(points: int = 200) → pandas.DataFrame[source]

curve() as a two-column frame, for export.

headline() → str[source]

One sentence — the number, or the reason there is no number.

is_shallow() → bool[source]

Whether the curve barely bends across the tested range.

is_steep() → bool[source]

Whether the Hill slope is implausibly steep.

parameter_frame() → pandas.DataFrame[source]

One row per parameter: estimate and interval.

EC50 appears as its own row, back-transformed, and its estimate is NaN when the experiment does not bound it — the frame carries the same refusal the object does, so an exported CSV cannot quietly disagree with the screen.

points_frame() → pandas.DataFrame[source]

The fitted observations with their fitted values and residuals.

predict(x) → numpy.ndarray[source]

The fitted response at x.

Parameters:

x – concentration(s), in the fitted units; evaluated with this result’s model and parameters.

report() → str[source]

The whole story, as the panel prints it and a report file writes it.

summary_row() → Dict[str, Any][source]

One flat record — the row this curve gets in a results table.

property ec50_fold_uncertainty: float | None[source]

sqrt(high / low) — the interval as a multiplicative factor.

The natural way to state a potency’s uncertainty: “1.0 µM, within a factor of 1.6”. None when either side is open.

property has_replicates: bool[source]

Whether any concentration was measured more than once.

property model_function: Callable[..., numpy.ndarray][source]

four_parameter_logistic() or five_parameter_logistic().

property n_parameters: int[source]

4 or 5 — what every degrees-of-freedom count here is against.

property parameters: Tuple[float, ...][source]

The vector this result’s model takes.

(bottom, top, log10_ec50, hill) under MODEL_4PL and that with asymmetry appended under MODEL_5PL — in both cases exactly what model_function expects, so predict is one line and no caller has to know which model it is holding.

property span: float[source]

how much of a window the curve moves through.

Type:

top - bottom

property status: str[source]

STATUS_FITTED or STATUS_UNBOUNDED.

class spacr.qt.widgets.dose_response.DoseResponseSet[source]

Every curve on a plate, in the order the levels were seen.

Parameters:
  • fits – one GroupFit per level.

  • spec – the spec they were all fitted under.

__iter__()[source]

Iterate the GroupFit records.

__len__() → int[source]

How many levels were attempted.

get(group: str) → GroupFit | None[source]

The fit for one level, or None.

Parameters:

group – the level name to look up, as stored in GroupFit.group ("" for an ungrouped fit).

headline() → str[source]

One sentence about the whole plate.

refusals() → Tuple[GroupFit, ...][source]

Every level the engine declined to fit.

report() → str[source]

Every curve’s report, one after another, under a summary line.

results() → Tuple[DoseResponseResult, ...][source]

Every level that produced a curve, bounded or not.

table() → pandas.DataFrame[source]

One row per level — the results grid, with refusals in it.

Columns: group, status, n, concentrations, ec50 and its interval, ec50_unconstrained, hill, top, bottom, r_squared, rse, lack_of_fit_p, note. ec50 is NaN for anything not STATUS_FITTED, and note says which of the two reasons it is.

property groups: Tuple[str, ...][source]

The level names, in order.

class spacr.qt.widgets.dose_response.DoseResponseSpec[source]

Which columns to fit and under what policy.

Frozen and JSON round-tripping, like spacr.qt.widgets.pca_model.PCASpec, so the analysis behind a figure is something a settings file or a methods section can carry verbatim.

Parameters:
  • concentration – column holding the dose. Only the positive values are fitted; zeros are the vehicle control and are reported separately.

  • response – column holding the measured response.

  • group – optional column giving one curve per level — per gene, per compound. None fits the table as a single series.

  • ci_method – CI_PROFILE (default) or CI_WALD.

  • confidence – nominal coverage, strictly between 0 and 1.

  • unit – concentration unit, for the sentences only. It never enters the arithmetic; an EC50 is reported in whatever the column is in.

  • direction – DIRECTION_AUTO (default) or a pinned direction.

  • allow_non_monotone – fit a bell-shaped series anyway. Offered because a user may know that the reversal is one bad well; never the default, and the result keeps the check so the caveat survives.

  • max_reversal – the monotonicity() threshold.

  • model – MODEL_4PL (default) or MODEL_5PL. The asymmetric model is a choice the user makes and never one the data makes for them: it is offered because real asymmetry exists and a 4PL absorbs it into a displaced EC50, and it is not the default because the fifth parameter is weakly identified on the eight or ten concentrations a plate actually carries. A fit that chose 5PL reports whether the extra parameter earned itself — see DoseResponseResult.asymmetry_p.

Raises:

DoseResponseError – on an unknown method, direction or model, or a confidence outside (0, 1) — at the point the spec is built, not halfway through a plate.

__post_init__() → None[source]

Normalise the column names and validate the fit settings.

Raises:

DoseResponseError – if ci_method, direction or model is not one this module offers; if confidence is not strictly between 0 and 1 – it is a coverage probability, so a 95% interval is 0.95 and not 95; or if max_reversal is not a fraction of the response span in (0, 1].

describe() → str[source]

One line, for a figure caption.

classmethod from_dict(payload: Mapping[str, Any]) → DoseResponseSpec[source]

Rebuild from to_dict().

Unknown keys are ignored and missing keys defaulted, so an analysis written by another build of spaCR still opens.

Parameters:

payload – mapping as written by to_dict(); only the spec’s own field names are used.

classmethod from_json(text: str) → DoseResponseSpec[source]

Inverse of to_json().

Parameters:

text – JSON text as written by to_json().

to_dict() → Dict[str, Any][source]

A plain dict, for JSON or a settings file.

to_json() → str[source]

to_dict() as sorted JSON.

with_ci_method(method: str) → DoseResponseSpec[source]

A copy using a different interval.

Parameters:

method – CI_PROFILE or CI_WALD; any other value raises DoseResponseError.

with_columns(concentration: str, response: str, group: str | None = None) → DoseResponseSpec[source]

A copy pointed at different columns.

Parameters:
  • concentration – column holding the dose.

  • response – column holding the measured response.

with_unit(unit: str) → DoseResponseSpec[source]

A copy that says the concentrations are in unit.

Parameters:

unit – concentration unit for the sentences; surrounding whitespace is stripped.

class spacr.qt.widgets.dose_response.GroupFit[source]

One level of the grouping column: a result, or the refusal.

Both are first-class. A plate where three compounds fit and one is bell-shaped has four rows in its table, and the fourth says why — hiding it would turn a refusal into a missing row, which reads as “no data”.

Parameters:
  • group – the level.

  • result – its DoseResponseResult, or None.

  • error – the refusal message, or None.

  • n_rows – rows the group had before anything was dropped.

summary_row() → Dict[str, Any][source]

The row this level gets in the results table, refusal included.

property status: str[source]

STATUS_REFUSED, STATUS_UNBOUNDED or STATUS_FITTED.

class spacr.qt.widgets.dose_response.HormesisCheck[source]

Whether the low-dose end of the series is stimulated, and by how much.

monotonicity() can see that a series turns around. It cannot say which turn it is, and the two turns a concentration series produces mean opposite things: a collapse at the top dose is usually the compound killing whatever was being counted, and the fix is to drop that dose, while a hump at the bottom is hormesis, and dropping doses there throws the finding away. This check names which one it is by fitting the model that has a hump in it and asking whether the hump paid for itself.

Parameters:
  • is_hormetic – the verdict, and it needs all four criteria below.

  • stimulation – the fitted Brain–Cousens coefficient, in response units per concentration unit. Positive is a hump.

  • max_stimulation – the largest departure from the control plateau the fitted hormetic curve reaches inside the tested range, in response units.

  • max_stimulation_fraction – that departure over the fitted curve’s response span — the number the minimum-effect threshold is applied to.

  • peak_dose – where that maximum sits, or None when there is no fitted curve to read it off.

  • f_statistic – extra-sum-of-squares F for the one added parameter.

  • p_value – its p value, on 1 and dof degrees of freedom.

  • delta_aic – corrected AIC of the monotone fit minus that of the hormetic fit. Positive favours hormesis.

  • sse_monotone – residual sum of squares of the 4PL.

  • sse_hormetic – residual sum of squares of the Brain–Cousens fit.

  • n_obs – observations both models were fitted on.

  • dof – residual degrees of freedom of the hormetic fit, n - 5.

  • ec50_monotone – the EC50 the monotone fit reports, so the cost of ignoring the hump is visible as a number rather than an argument.

  • ec50_hormetic – the EC50 the hormetic fit reports.

  • min_effect – the HORMESIS_MIN_EFFECT this used.

  • alpha – the HORMESIS_ALPHA this used.

  • min_delta_aic – the HORMESIS_MIN_AIC this used.

  • note – why the verdict is what it is — the first criterion that failed, or the caveat that survives a positive verdict.

describe() → str[source]

One line, for a caption, a caveat or a refusal message.

class spacr.qt.widgets.dose_response.InteractionSurface[source]

A checkerboard’s interaction, per cell, with its own axes.

THE SURFACE IS THE RESULT AND A SINGLE INDEX IS NOT. One number for a whole checkerboard hides exactly the concentration-dependent structure that makes synergy interesting. Real combinations are frequently synergistic in one corner of the grid and additive or antagonistic in another, and a mean over the grid reports neither.

SIGN CONVENTION, stated because every paper states a different one: POSITIVE MEANS MORE EFFECT THAN EXPECTED – synergy for an inhibition assay. The expected surface is what the model predicts from the two single-agent curves; excess is observed minus expected.

Parameters:
  • model – SYNERGY_BLISS or SYNERGY_LOEWE.

  • dose_a – the unique concentrations of agent A, ascending.

  • dose_b – the unique concentrations of agent B, ascending.

  • observed – (len(dose_a), len(dose_b)) effect, 0 to 1.

  • expected – what model predicts for each cell.

  • excess – observed - expected. NaN where a cell was not tested.

  • n_cells – cells with an observation.

  • note – what could not be computed, and why.

summary() → Dict[str, Any][source]

Headline numbers, each said to be over the grid rather than of it.

Deliberately NOT a synergy index. The strongest cell and where it sits are reportable; a mean over the whole grid is the number this class exists to avoid, so it is absent rather than provided-with-a-warning.

class spacr.qt.widgets.dose_response.MonotonicityCheck[source]

Whether the concentration-ordered response only ever goes one way.

Computed on the per-concentration medians, not the raw points. The median limits the influence of one outlier well on the fit decision.

Parameters:
  • doses – the distinct positive concentrations, ascending.

  • medians – the median response at each of them.

  • span – max(medians) - min(medians), the yardstick everything else is measured against.

  • reversal – the excursion that no monotone trend explains, in response units. It is the smaller of the largest fall after a rise and the largest rise after a fall, so a clean increasing series scores ~0 (its falls are noise), a clean decreasing series scores ~0, and a bell scores most of the span whichever way you read it.

  • reversal_fraction – reversal / span.

  • sign_changes – how many times the direction of the successive median differences flips, ignoring steps flatter than FLAT_FRACTION of the span.

  • turning_points – the concentrations at which those flips happen.

  • spearman_rho – rank correlation of median response against log concentration. Near zero with a large reversal is the signature of a symmetric bell.

  • threshold – the reversal at which this check would have failed.

  • is_monotone – the verdict.

describe() → str[source]

One line, for a caption or an error message.

class spacr.qt.widgets.dose_response.PlateReport[source]

What one plate’s controls said, and whether the plate may be fitted.

ONE ROW PER PLATE, REFUSALS INCLUDED. A plate that is dropped leaves a report saying why it was dropped, so a user comparing eight plates and seeing six curves can find the other two without re-running anything.

Parameters:
  • status – STATUS_FITTED when the plate normalised, or STATUS_REFUSED – the same vocabulary the curve fits speak.

  • zprime – the plate’s Z-factor, or None when it has no Z’ because a control had fewer than two wells. None is not a failure; it is the absence of a number, and is reported as such.

  • note – the sentence to show the user. Empty when nothing is wrong.

  • plate – the plate identifier this report is about, as it appears in the plate column of the frame.

  • n_positive – how many positive-control wells were found on it. Reported even when the plate is refused, because “two” and “none” are different problems and the note alone does not distinguish them.

  • n_negative – the same for negative controls.

summary_row() → Dict[str, Any][source]

One row for the plate table, refusal included.

property usable: bool[source]

Whether wells on this plate carry a normalised response.

class spacr.qt.widgets.dose_response.PlateSpec[source]

Which column is the plate, and which wells on it are the controls.

THE ENGINE HAD NO NOTION OF A PLATE, which is why it could fit a clean EC50 on a plate the rest of the package already knew was bad. This is that notion: the plate column, the control column, and the levels in it that mean “full effect” and “no effect”.

Frozen and JSON round-tripping like DoseResponseSpec, so the normalisation behind a figure travels with the fit that used it.

Parameters:
  • plate – column identifying the plate. Required.

  • control – column naming each well’s control role. Required.

  • positive – levels of control that are the positive control – the full-effect wells, which normalise to 100.

  • negative – levels that are the negative control – vehicle or untreated, which normalise to 0.

  • min_zprime – refuse every plate whose Z’ falls below this, and say the Z’ in the refusal. None (default) gates nothing and still reports the Z’ beside each plate. ZPRIME_MARGINAL is the conventional 0.5 if you want one.

Raises:

DoseResponseError – when a column or a control level is missing, at the point the spec is built rather than halfway through a plate.

__post_init__() → None[source]

Normalise the names and insist both controls exist.

Raises:

DoseResponseError – when plate or control is blank, or when either control has no levels – percent inhibition is a two-point scale and one control cannot define it.

classmethod from_json(payload: Mapping[str, Any]) → PlateSpec[source]

Rebuild from to_json(), validating on the way in.

Parameters:

payload – the mapping to_json() produced. Missing keys fall back to the field defaults rather than raising, so a spec written by an older version still loads.

to_json() → Dict[str, Any][source]

A plain dict, for a settings file or a methods section.

class spacr.qt.widgets.dose_response.PooledFit[source]

One EC50 across replicate plates, with plate as a random effect.

THREE EC50s AND AN EYEBALL is what this replaces. A user with three replicate plates today fits three curves and averages the numbers by hand, which throws away how well each one was determined and says nothing about whether the three agreed.

TWO-STAGE, NOT ONE. Each plate is fitted on its own – by the same fit_dose_response() that fits everything else, with the same refusals – and the per-plate log10 EC50s are then combined with a random-effects weight. One joint nonlinear mixed model would be the other way to do it; it would also mean a second fitting path with a second set of failure modes, and a plate that fit_dose_response() refuses would have to be refused again, differently, inside it. This way a refusal on one plate stays exactly the refusal this module already speaks.

RANDOM, NOT FIXED. Fixed-effect pooling assumes every plate measures the same value and differs only by noise. Three tight plates that disagree then give a narrow interval around a value none of them support. The DerSimonian-Laird estimate of the between-plate variance is added to each plate’s own, so real variation between plates widens the answer instead of being weighted away.

Parameters:
  • status – STATUS_FITTED, or STATUS_REFUSED when too few plates fitted or the plates disagree beyond what their own uncertainty explains.

  • ec50 – the pooled EC50, or None when refused.

  • tau – the between-plate SD on the log10 scale – it shows how far the plates agree about this compound, which no average of three EC50 values can report.

  • i_squared – the share of the observed spread that is real rather than sampling noise, in [0, 1].

  • q – Cochran’s Q against the null that every plate measured the same EC50.

  • q_p – the p-value of that Q.

  • per_plate – the individual fits, kept so the pooled number can always be taken apart again.

  • note – why, when refused or when the plates sit uneasily together.

  • log10_ec50 – the pooled estimate on the log10 scale, which is where the pooling is actually done – EC50s are log-normal, so averaging them in linear units weights the high plates more than the data warrants.

  • log10_se – the standard error of that estimate, on the same scale.

  • ec50_low – the low end of the confidence interval, back on the linear scale the user reads.

  • ec50_high – its high end.

summary_row() → Dict[str, Any][source]

One row for the results table, refusal included.

property reproducible: bool[source]

Whether the plates agreed well enough for the pooled number.

class spacr.qt.widgets.dose_response.SelectivityIndex[source]

Host toxicity over parasite killing, with the interval it deserves.

THE NUMBER THAT DECIDES WHETHER ANYBODY CARES about an anti-parasitic compound is not the EC50, it is this ratio. A compound that kills the parasite at 1 uM and the host monolayer at 1.2 uM is not a hit, and an EC50 quoted with a clean confidence interval says nothing about that.

QUOTED WITH ITS INTERVAL OR NOT AT ALL, for the same reason this module already refuses a naked EC50: a ratio of two uncertain numbers is more uncertain than either of them, and a selectivity index without its interval invites a reader to treat 1.2 and 12 as the same kind of claim.

Parameters:
  • status – STATUS_FITTED, STATUS_UNBOUNDED or STATUS_REFUSED, reusing the vocabulary the single-curve fits already speak rather than inventing a second one.

  • index – the quotable ratio, or None when it is not quotable.

  • index_low – lower end of the interval, or None for an open side.

  • index_high – upper end, or None for an open side.

  • log10_index – the difference of the two log10 midpoints, always present when both curves fitted. An extrapolation when either EC50 is unbounded, exactly as DoseResponseResult.ec50_unconstrained is.

  • host – the host-viability fit.

  • pathogen – the parasite fit.

  • note – why, when the index is refused or one-sided.

summary_row() → Dict[str, Any][source]

One row for the results table, refusal included.

spacr.qt.widgets.dose_response.bliss_surface(dose_a, dose_b, response, *, fit_a: DoseResponseResult, fit_b: DoseResponseResult) → InteractionSurface[source]

Bliss independence over a checkerboard.

THE MODEL IN ONE LINE: if two agents act independently, the fraction surviving both is the product of the fractions surviving each, so the expected effect is Ea + Eb - Ea*Eb. Excess over that is synergy.

BLISS NEEDS NOTHING FROM THE COMBINATION FITS, which is why it is the cheaper of the two and the one to reach for first: both single-agent curves already give an effect at every concentration on the grid.

Parameters:
  • dose_a – agent A concentration per well.

  • dose_b – agent B concentration per well.

  • response – measured response per well, same length.

  • fit_a – the single-agent fit for A (B held at zero).

  • fit_b – the single-agent fit for B.

Returns:

an InteractionSurface.

spacr.qt.widgets.dose_response.brain_cousens(x, bottom, top, log10_ec50, hill, stimulation)[source]

The Brain–Cousens hormesis model: a 4PL with a low-dose hump on it.

y = four_parameter_logistic(x, ...) - sign(hill)·stimulation·x·w(x) with w(x) = 1 / (1 + (x / EC50) ** |hill|), the weight that is 1 at zero dose, ½ at the EC50 and 0 above it.

For an inhibition curve (hill < 0) that is the published model (Brain & Cousens, Weed Research 29, 1989) written in this module’s parameterisation: w is then exactly the 4PL’s own sigmoid factor, and the expression collapses to bottom + (top - bottom + f·x)·w, which is Brain–Cousens term for term. For an activation curve it is that model reflected, because hormesis is a departure from the control against the direction the compound eventually pushes the readout, and on an activation series that is a low-dose dip rather than a low-dose rise. Writing it as one function with the sign taken from the Hill slope means the same hypothesis test covers both, instead of a second model nobody would remember to run.

stimulation == 0 is the 4PL exactly, which is what makes the pair nested and hormesis()’ F test a one-parameter test.

Parameters:
  • x – concentration(s).

  • bottom – the smaller plateau of the underlying 4PL.

  • top – the larger plateau of the underlying 4PL.

  • log10_ec50 – base-10 log of the underlying 4PL’s EC50.

  • hill – slope; its sign carries the direction, as everywhere here.

  • stimulation – the hormesis term’s coefficient, in response units per concentration unit. Positive is hormesis; zero is no hormesis; negative is a curve that leaves the control plateau faster than a 4PL, which is a shape correction and is never called hormesis.

Returns:

the modelled response, same shape as x.

spacr.qt.widgets.dose_response.candidate_concentration_columns(frame: pandas.DataFrame) → Tuple[str, ...][source]

Columns worth offering as the concentration axis.

Numeric, not a key or free text, and carrying at least MIN_DOSES distinct positive values — a column that never takes four different positive values cannot be a dilution series whatever it is called, and offering it only produces a refusal one click later.

Note what is not required: CONTINUOUS. The shared classifier calls a low-cardinality numeric column categorical, which is the right call for cell_count and the wrong one here — an eight-point dilution series has exactly eight levels by design, so the classifier’s own rule would hide every concentration column in the project. The classifier is still what excludes object keys and free text (UNPLOTTABLE), which is the part of its judgement that transfers; the continuous/categorical split does not identify dose columns reliably.

Parameters:

frame – the table whose columns are screened; they are returned in sorted name order.

spacr.qt.widgets.dose_response.candidate_response_columns(frame: pandas.DataFrame) → Tuple[str, ...][source]

Columns worth offering as the response axis: every continuous one.

Here the classifier’s continuous/categorical split is the right cut: a response is a measured quantity, and a column with four levels is a label or a count rather than something a sigmoid passes through.

Parameters:

frame – the table whose columns are classified; the continuous ones are returned in sorted name order.

spacr.qt.widgets.dose_response.checkerboard_from_frame(frame: pandas.DataFrame, *, dose_a: str, dose_b: str, response: str) → Checkerboard[source]

Read a checkerboard off a well table, single-agent axes and all.

Parameters:
  • frame – one row per well.

  • dose_a – column holding agent A’s concentration.

  • dose_b – column holding agent B’s.

  • response – column holding the measurement.

Returns:

a Checkerboard ready for bliss_surface(), loewe_surface() and the single-agent fits they need.

Raises:

DoseResponseError – when a column is missing, when either agent has no single-agent wells – without them there is no curve to predict the combination from, and a surface computed against the combination wells themselves would be comparing the data to itself – or when no well has both agents present, which is a pair of dose series and not a checkerboard.

spacr.qt.widgets.dose_response.fit_dose_response(doses: Sequence[float], responses: Sequence[float], spec: DoseResponseSpec | None = None, *, group: str = '') → DoseResponseResult[source]

Fit one concentration series, or refuse it.

The whole policy is in the module docstring. The short version: zeros are vehicle controls and are reported rather than logged; a series that is not monotone is refused rather than fitted; the fit is canonicalised so the Hill slope carries the direction; the EC50 is reported only when the experiment actually locates it, and otherwise as a one-sided bound with DoseResponseResult.ec50 set to None.

Two things happen here that the model choice does not change. A series whose low-dose end rises against the trend is tested for hormesis (hormesis()) whether or not it also fails the monotonicity check, because a hump can be worth a fifth of the response span and still be inside MAX_REVERSAL. A hormetic series that fails the monotonicity check is refused as hormesis rather than as the cytotoxicity a bell shape usually is — a different sentence, a different thing to do next. A hormetic series that passes it is fitted, and carries the finding as a caveat and as DoseResponseResult.hormesis.

The 5PL is fitted from the 4PL solution, so the asymmetric fit starts at the symmetric answer and can only leave it for something better; the improvement is then reported as an F test on the one extra parameter.

Parameters:
  • doses – concentrations, one per observation, replicates included.

  • responses – the matching responses.

  • spec – a DoseResponseSpec. Only its policy fields matter here — including model; the column names are for fit_frame().

  • group – a label carried through onto the result.

Raises:

DoseResponseError – for every series that cannot carry the chosen model — too few concentrations, a flat response, a negative concentration, a bell shape, a hormetic curve, or an optimiser that never converged. The message says which and what to do about it.

spacr.qt.widgets.dose_response.fit_frame(frame: pandas.DataFrame, spec: DoseResponseSpec) → DoseResponseSet[source]

Fit one curve per level of spec.group — the whole plate at once.

A refusal in one group is kept beside that group and does not stop the others: a plate where one compound is cytotoxic at the top dose should still report the other twenty-three, with the cytotoxic one visibly labelled rather than missing.

Parameters:
  • frame – the table holding the concentration, response and optional grouping columns named by spec.

  • spec – the columns to fit and the fitting policy; with a group column, one curve is fitted per level.

Raises:

DoseResponseError – only for something wrong with the table — a column that is not there, or a group column with no levels. Per-curve failures land in GroupFit.error.

spacr.qt.widgets.dose_response.five_parameter_logistic(x, bottom, top, log10_ec50, hill, asymmetry)[source]

The 5PL curve, parameterised so log10_ec50 is still the EC50.

y = bottom + (top - bottom) / (1 + a·10 ** ((log10_ec50 - log10 x) * hill)) ** asymmetry, with a = 2 ** (1 / asymmetry) - 1.

That scale factor is the whole point of this function. Written the way the 5PL usually is —

\[y = \mathrm{bottom} + \frac{\mathrm{top} - \mathrm{bottom}} {\left(1 + 10^{(c - \log_{10}x)h}\right)^{s}}\]

— the parameter c is not the half-maximal concentration once s != 1: at x = 10**c the response is bottom + (top - bottom) / 2**s, which for s = 3 is an eighth of the way up rather than half. A 5PL reported as if c were the EC50 is wrong by a factor that grows with the asymmetry, and it is a published mistake often enough to have its own literature (Gottschalk & Dunn, Anal. Biochem. 343, 2005). Scaling the exponential by 2 ** (1 / s) - 1 moves the half-maximal point back to x = EC50 exactly, so:

  • asymmetry == 1 reproduces four_parameter_logistic() term for term, which is what makes the two models nested and the F test in fit_dose_response() legitimate;

  • every downstream rule in this module — the boundedness tests, the profile interval, the back-transformed reporting — keeps reading log10_ec50 as the same quantity under both models, and none of them had to learn a second meaning.

Parameters:
  • x – concentration(s). 0 evaluates to the low-dose plateau, as in four_parameter_logistic().

  • bottom – the smaller plateau.

  • top – the larger plateau.

  • log10_ec50 – base-10 log of the half-maximal concentration. It is the EC50 here, not the inflection parameter.

  • hill – slope. Negative is inhibition, positive is activation.

  • asymmetry – the exponent. 1 is the symmetric 4PL; below 1 the curve leaves the low-dose plateau slowly and reaches the high-dose one abruptly, above 1 the reverse.

Returns:

the modelled response, same shape as x.

Raises:

DoseResponseError – if asymmetry is not strictly positive — the exponent of a nonneg quantity cannot be zero or negative here, and returning nan would let the optimiser wander into it.

spacr.qt.widgets.dose_response.four_parameter_logistic(x, bottom, top, log10_ec50, hill)[source]

The 4PL curve, parameterised in log10(EC50).

y = bottom + (top - bottom) / (1 + 10 ** ((log10_ec50 - log10 x) * hill)).

At x == EC50 the exponent is zero and the response is exactly halfway between the plateaus, which is the definition the EC50 is quoted under.

Parameters:
  • x – concentration(s), in the user’s units. 0 is evaluated at its limit (the low-dose plateau) rather than raising, because the clipped exponent below makes log10(0) = -inf well behaved; the fit itself never sees a zero — see fit_dose_response().

  • bottom – the smaller plateau, after canonicalisation.

  • top – the larger plateau.

  • log10_ec50 – base-10 log of the half-maximal concentration.

  • hill – slope. Negative is inhibition, positive is activation.

Returns:

the modelled response, same shape as x.

spacr.qt.widgets.dose_response.hormesis(doses: Sequence[float], responses: Sequence[float], *, direction: str = DIRECTION_AUTO, min_effect: float = HORMESIS_MIN_EFFECT, alpha: float = HORMESIS_ALPHA, min_delta_aic: float = HORMESIS_MIN_AIC) → HormesisCheck[source]

Is the low-dose end of this series stimulated, or is it noise?

Fits a 4PL and a Brain–Cousens curve (brain_cousens()) to the same points and compares them. The two models are nested — Brain–Cousens is the 4PL at stimulation = 0 — so the comparison is the classical extra-sum-of-squares F test on one degree of freedom, which under Gaussian errors is the likelihood-ratio test written in the units this module already reports. It is required to agree with a corrected AIC, and both are required to agree with a minimum effect size.

Four criteria, and all four have to hold. Any one of them alone is a way to be wrong:

  1. the fitted stimulation is positive — a negative coefficient makes the curve leave the control plateau faster, which is a 4PL that does not quite fit rather than a compound that stimulates;

  2. p < alpha on the F test;

  3. ΔAICc >= min_delta_aic, computed with the small-sample correction because these are five- and six-parameter models on twenty-odd wells;

  4. the hump reaches min_effect of the fitted response span — the minimum-effect threshold, without which a very tight assay reports hormesis at 2% and a reader acts on it.

Why the effect is measured against the response span and not as a percentage of the control, which is the convention in the hormesis literature: this module normalises plates to percent inhibition, where the control is 0 by construction and a percentage of it is either infinite or meaningless. The span is the window the curve moves through under either scaling.

The F test is two-sided and the hypothesis is one-sided, since only a positive stimulation is hormesis. Criterion 1 does the one-sided part by inspection rather than by halving the p value, so the reported p is conservative by about a factor of two. Stated rather than corrected: a conservative p on a screen that already refuses this data is the right direction to be wrong in.

Parameters:
  • doses – concentrations, one per observation. Zeros are vehicle controls and are dropped, as everywhere in this module.

  • responses – the matching responses.

  • direction – DIRECTION_AUTO or a pinned direction. It sets which way “stimulated” points — for an inhibition series hormesis is a rise above control, for an activation series a dip below it.

  • min_effect – the minimum-effect threshold, as a fraction of the fitted response span. See HORMESIS_MIN_EFFECT.

  • alpha – significance the F test has to reach.

  • min_delta_aic – corrected-AIC gap the hormetic fit has to win by.

Returns:

a HormesisCheck. Read is_hormetic; when it is False the note says which criterion failed.

Raises:

DoseResponseError – only for data that cannot be read at all — a negative concentration, or a concentration and response column of different lengths. Too few points to test is a False verdict with a note, not an exception: “we could not test this” is an answer.

spacr.qt.widgets.dose_response.loewe_surface(dose_a, dose_b, response, *, fit_a: DoseResponseResult, fit_b: DoseResponseResult) → InteractionSurface[source]

Loewe additivity over a checkerboard, as a combination index.

THE MODEL IN ONE LINE: if two agents are dilutions of one another, then reaching an effect costs a constant total dose, so a/Da + b/Db = 1 where Da and Db are the single-agent doses giving that same effect. Below 1 is synergy.

REPORTED AS EXCESS RATHER THAN AS THE INDEX ITSELF, so that the sign convention matches Bliss and a reader comparing the two surfaces is not also flipping a comparison in their head: excess = 1 - CI, positive for synergy.

WHY IT CAN BE NaN WHERE BLISS IS NOT: Loewe needs the INVERSE curve – the dose achieving an observed effect – and that dose does not exist when the observed effect lies outside a single agent’s own plateaus. A combination that kills more than either agent can alone has no Loewe answer, and NaN is the honest one.

Parameters:
  • dose_a – agent A concentration per well.

  • dose_b – agent B concentration per well.

  • response – measured response per well.

  • fit_a – the single-agent fit for A.

  • fit_b – the single-agent fit for B.

spacr.qt.widgets.dose_response.monotonicity(doses: Sequence[float], responses: Sequence[float], *, max_reversal: float = MAX_REVERSAL) → MonotonicityCheck[source]

Is this concentration series consistent with a single sigmoid?

A 4PL is monotone by construction. Data that is not monotone is not described by one, and fitting it anyway returns a confident EC50 for a curve of the wrong shape — the specific failure this module exists to prevent.

The test is an excursion test rather than a sign-change count, because a sign-change count cannot tell a 5% wobble on a plateau from a collapse at the top dose, and on a ten-point series with replicates the wobbles are guaranteed. Both numbers are reported; only the excursion decides.

Parameters:
  • doses – positive concentrations, one per observation. Replicates allowed and expected.

  • responses – the matching responses.

  • max_reversal – the fraction of the response span an excursion against the trend may reach. See MAX_REVERSAL.

Returns:

a MonotonicityCheck; read is_monotone.

spacr.qt.widgets.dose_response.normalise_to_controls(frame: pandas.DataFrame, spec: PlateSpec, *, response: str, out: str = PERCENT_COLUMN) → Tuple[pandas.DataFrame, Tuple[PlateReport, ...]][source]

Percent inhibition against each plate’s own controls.

RAW RESPONSES ARE NOT COMPARABLE ACROSS PLATES. Two plates read on different days differ in absolute signal by more than most compounds move it, so three replicate plates fitted raw produce three EC50s whose spread is mostly instrument drift. Scaling each plate to its own controls – negative reads 0, positive reads 100 – removes exactly that and leaves the biology.

percent = 100 * (value - mean_negative) / (mean_positive - mean_negative)

The formula is signed and direction-agnostic on purpose: whichever way the raw readout runs, the positive control reads 100 by construction, so a viability readout and a burden readout normalise the same way and the fit downstream does not need to be told which it got.

REFUSED RATHER THAN SCALED BY NOISE. When a plate’s two controls do not separate there is no assay window, and dividing by that near-zero difference would turn well-to-well noise into hundreds of percent inhibition and a confident EC50 on a plate that measured nothing. Such a plate’s rows come back with a NaN response and a PlateReport saying so.

Parameters:
  • frame – one row per well, with the plate, control and response columns the spec and this call name.

  • spec – the plate, its control column and its two control levels.

  • response – the raw column to normalise.

  • out – column to write the percent into. Defaults to PERCENT_COLUMN; pass another name to keep several readouts (host viability and parasite burden, say) side by side.

Returns:

(frame, reports) – a copy of the frame carrying out, and one PlateReport per plate in the order the plates first appear. Rows on a refused plate carry NaN, so a caller that fits the whole table drops those plates without having to filter first.

Raises:

DoseResponseError – when a named column is missing, or when no plate has both controls – there is nothing to normalise against and a frame of NaN would be a worse answer than a sentence.

spacr.qt.widgets.dose_response.plate_reports(frame: pandas.DataFrame, spec: PlateSpec, *, response: str) → Tuple[PlateReport, ...][source]

The per-plate verdict alone, without normalising anything.

For the screen that wants to show the plate table before the user has chosen a readout to fit, and for a caller that only wants to know which plates would be dropped. A table where every plate is refused returns its refusals rather than raising – here the refusals ARE the answer.

Parameters:
  • frame – the long-format table, one row per well.

  • spec – which column names the plate, which the control, and which control values are the two ends.

  • response – the readout column whose control means decide the plate’s Z’. Keyword-only, because a plate’s verdict is about a PARTICULAR readout and passing it positionally invites reading the report as a property of the plate alone.

Raises:

DoseResponseError – when a named column is missing.

spacr.qt.widgets.dose_response.pool_across_plates(fits: Mapping[str, DoseResponseResult], *, confidence: float = DEFAULT_CONFIDENCE, max_heterogeneity: float = MAX_HETEROGENEITY) → PooledFit[source]

Combine per-plate fits into one EC50 with plate as a random effect.

POOLED ON THE LOG10 SCALE, because that is the scale the EC50 is estimated on and the scale its interval is symmetric on. Averaging three EC50s of 1, 10 and 100 uM arithmetically gives 37 uM; pooling their logarithms gives 10, which is the middle of the three in the only sense that matters for a concentration.

Parameters:
  • fits – plate label to that plate’s fit. Only fits that are STATUS_FITTED with a closed interval can carry a weight; the rest are counted, named in the note and left out of the arithmetic, because a plate whose EC50 is unbounded has no variance to weight by and dropping it silently would make the pooled interval look better than the experiment was.

  • confidence – coverage for the pooled interval.

  • max_heterogeneity – refuse above this I-squared.

Returns:

a PooledFit, refused rather than empty when the plates cannot support a single number.

Raises:

DoseResponseError – when max_heterogeneity is not in (0, 1].

spacr.qt.widgets.dose_response.pool_frame(frame: pandas.DataFrame, spec: DoseResponseSpec, *, plate: str, max_heterogeneity: float = MAX_HETEROGENEITY) → PooledFit[source]

Fit each plate in frame on its own, then pool them.

The convenience over pool_across_plates() for the common case: one table, one compound, a plate column. A plate that raises DoseResponseError is kept out of the pool and named in the note, exactly as fit_frame() keeps one bad compound from taking a plate down.

Parameters:
  • frame – the long-format table, one row per well, with every replicate plate in it.

  • spec – the fit specification, applied unchanged to every plate – which is what makes the per-plate EC50s comparable in the first place.

  • plate – the column identifying the replicate.

Raises:

DoseResponseError – when plate is not a column, or when no plate produced a fit at all – there is nothing to pool and a refusal with no plates in it would say nothing about why.

spacr.qt.widgets.dose_response.selectivity_index(pathogen: DoseResponseResult | None, host: DoseResponseResult | None, *, confidence: float | None = None) → SelectivityIndex[source]

Divide a host EC50 by a parasite EC50, and carry the uncertainty.

THE TWO FITS COME OFF ONE PLATE, which is the argument for computing this here rather than in a spreadsheet. spaCR segments host cell and pathogen as separate object types from the same image, so host viability and parasite burden are measured at the same doses in the same run. Most selectivity indices divide two numbers from two experiments and hope the conditions matched; these cannot fail to match.

THE INTERVAL IS PROPAGATED IN LOG SPACE, where the fit lives and where a ratio is a difference: log10 SI = log10 CC50 - log10 EC50, and the two variances add. Back-transforming at the end gives an interval that is asymmetric in linear space, which is the honest shape for a ratio.

REFUSAL PROPAGATES TOO. If either curve was refused the index is refused; if either EC50 is unbounded the index is unbounded, and whichever side of the interval the data still supports is reported rather than dropped – “at least 8-fold” is a useful sentence and this returns it.

Parameters:
  • pathogen – the parasite-burden fit, or None if it was refused.

  • host – the host-viability fit, or None if it was refused.

  • confidence – overrides the level carried by the fits.

Returns:

a SelectivityIndex, never an exception, because a refusal is a result the caller has to show.

Nested helpers

PooledFit.summary_row.num(value)

None as NaN, so a refused fit still fills its columns.

A refusal has no EC50, and leaving the cell empty would make the row a different shape from a fitted one – which is what a table cannot have. NaN is the value that says “not a number here” without changing the columns.

spacr/qt/widgets/dose_response.py:3899

_effect_curve.effect(dose: np.ndarray) → np.ndarray

The affected fraction at each dose, in [0, 1].

Zero and negative doses are clamped to a tiny positive number rather than refused: a checkerboard’s first row IS zero, and a 4PL has no value there because log10(0) is undefined. The clamp puts them at the curve’s own baseline, which is what an untreated well measures.

Parameters:

dose – concentrations, any shape.

Returns:

affected fraction, same shape.

spacr/qt/widgets/dose_response.py:3173

_profile_five_sse.objective(point) → float

SSE at one (log slope magnitude, log asymmetry) point.

spacr/qt/widgets/dose_response.py:2367

fit_dose_response.wald(index: int) → Tuple[float | None, float | None]

One parameter’s Wald interval, or None when it cannot be formed.

Returns None rather than an interval when the covariance is unusable: an interval computed from a bad covariance looks like a result and is not one.

spacr/qt/widgets/dose_response.py:2687

selectivity_index._ratio(numerator, denominator)

One end of the interval, or None when that end is open.

Returns None rather than raising or substituting a sentinel: an open side of a one-sided index is a fact about the experiment, and a number here would make it look bounded.

Parameters:
  • numerator – a host EC50 bound, or None.

  • denominator – a parasite EC50 bound, or None.

spacr/qt/widgets/dose_response.py:3032