Site Feasibility Analyser

Methodology

The methodology section of the project specification (SPEC.md §4–§7), reproduced verbatim, with the current dataset's numbers at the top.

Current dataset

Loading live numbers from the API…

Studies total
…
Benchmark-eligible trials
…
Rate-eligible trials
…
Recruiting now
…
Data refreshed
…

Studies total: interventional studies pulled from the ClinicalTrials.gov API v2. Benchmark-eligible: completed trials with actual start and primary-completion dates that pass the duration filters below. Rate-eligible: benchmark-eligible trials that also list at least one site and pass the rate filters. Recruiting now: interventional trials with status RECRUITING or NOT_YET_RECRUITING.

4. Cohort matching

The benchmark cohort is chosen by walking down this ladder and stopping at the first level with at least MIN_COHORT = 30 trials:

LevelCondition matchPhase matchCountry filter
1Exact MeSH termExact≥1 site in any selected country
2Exact MeSH termExactNone (global)
3MeSH ancestor (nearest parent in the tree)ExactNone
4Exact MeSH termAny phaseNone
5MeSH ancestorAny phaseNone
  • The level used is always reported to the user in plain language (e.g. "No Phase 2 psoriasis trials had enough data, so this uses all skin disease trials at Phase 2").
  • If Level 5 still has < 30, return the cohort anyway with a prominent "very small cohort" warning; never return nothing.
  • Country-level rate tables (§3.2) use the same condition/phase match as the chosen level but compute each country's percentiles from trials with ≥1 site in that country. A country row needs ≥ 10 trials to show numbers.
  • sponsor_class filter is applied at every level if set to something other than ANY.

MeSH ancestor: ClinicalTrials.gov's derivedSection.conditionBrowseModule provides meshes (the direct terms) and ancestors. For Level 3/5 use the ancestor that appears in the largest number of cohort trials but is not a top-level category (exclude terms like "Diseases", "Neoplasms" alone, "Pathologic Processes" — maintain an exclusion list in etl/mesh_exclusions.py).

5. Metrics

5.1 Duration

duration_months = months_between(start_date, primary_completion_date)

Both dates must be ACTUAL type. Month arithmetic: (y2 - y1) * 12 + (m2 - m1); day-of-month is ignored because many dates are month-precision. Exclude duration_months < 1 or > 240.

5.2 Terminated trials

Terminated trials have real actual dates and enrollment but their duration is truncated. Default is to exclude them from duration and rate percentiles. With include_terminated = true, include them in the rate calculation only (their rate up to termination is still informative) but continue to exclude them from the duration histogram. State this in the UI.

5.3 Enrollment rate

rate_per_site_month = enrollment_actual / n_sites / duration_months
  • n_sites = number of entries in contactsLocationsModule.locations. Trials with 0 locations listed are excluded from rate calculations (they are still in the duration cohort).
  • Exclude rates > 50 subjects/site/month (data errors, or registries mislabelled as interventional).
  • Known limitation, stated in UI: duration includes treatment and follow-up, so this rate is a lower bound on the true recruitment rate, and forecasts are correspondingly conservative.

Per-country rate: for the country table, a trial contributes its overall rate_per_site_month to each country where it has ≥1 site (ClinicalTrials.gov does not publish per-country enrollment). This is a simplification; document it in methodology.

5.4 Forecast

Let r_p be the P-th percentile of rate_per_site_month pooled across trials that have ≥1 site in any of the selected countries (fall back to the global cohort if fewer than 10 such trials).

months_to_target(p)          = target_enrollment / (planned_sites * r_p)
sites_needed_for_12_months(p) = ceil(target_enrollment / (12 * r_p))

Round months to one decimal. Cap display at 120 months with "> 10 years".

Also compute enrollment_at_historical_median = planned_sites * r_50 * duration_p50 — "at a typical pace, a trial like yours enrolled about X subjects in the median duration" — shown as context under the forecast.

5.5 Country score

For each country c with cohort ≥ 10:

rate_norm(c)   = median_rate(c) / max over countries of median_rate
sat(c)         = recruiting_trials_now(c)        # same condition, current
score(c)       = rate_norm(c) / (1 + ln(1 + sat(c)))

Rank descending. The log damping keeps a country with 200 competing trials from being ranked equal to one with 2. Provide the "why" string with these rules:

  • rate_norm ≥ 0.75 and sat ≤ 5 → "High historical rate, low current competition"
  • rate_norm ≥ 0.75 and sat > 5 → "High historical rate but crowded right now"
  • rate_norm < 0.75 and sat ≤ 5 → "Moderate rate, little competition"
  • otherwise → "Moderate rate, crowded"

5.6 Saturation

Count distinct interventional trials with overallStatus ∈ {RECRUITING, NOT_YET_RECRUITING} whose condition matches (same level as chosen cohort) and which list ≥1 site in the country. Also sum those trials' site counts in that country.

5.7 City metrics

A city is identified by its country, name and ClinicalTrials.gov geoPoint. ClinicalTrials.gov geocodes every site to the centre of its city, so the point separates cities that share a name (Rochester, Minnesota and Rochester, New York) and merges the many spellings of a state or region. All hospitals in a city are therefore combined. Sites without a city or a geoPoint are excluded and counted (locations_without_city_point in the quality report).

For each city and condition (matched as in §5.6: direct term, or the whole subtree for an ancestor), over interventional trials of any phase and status that list ≥1 site in the city:

trials               = distinct trials with ≥1 site in the city
termination_share    = terminated / (completed + terminated)      # shown only if completed + terminated ≥ 10
recruiting_trials    = trials with overallStatus ∈ {RECRUITING, NOT_YET_RECRUITING}
recruiting_sites     = those trials' sites in the city

Withdrawn trials (stopped before enrolling anyone) and trials still running count towards trials but not towards the termination share. Cities are ranked by trials (ties: city name, then latitude, then longitude) and the top 10 per country are shown.

Stated in the UI: a trial can be terminated for reasons unrelated to any site (safety, efficacy, funding, sponsor decisions), so the termination share describes the trials a city took part in, not the performance of its sites. ClinicalTrials.gov does not publish enrollment or outcomes per site.

6. Data freshness and refresh

Full refresh weekly (Sunday 02:00 server time). The API reads whichever Parquet set is under data/current/ (a symlink swapped atomically by the ETL after a successful run). Frontend displays data_refreshed_at from /feasibility/api/meta.

7. Copy: caveats and explanations

Forecast caveat banner (verbatim):

These estimates are derived from historical ClinicalTrials.gov records. Duration is measured from study start to primary completion, which includes treatment and follow-up, so real enrollment periods are typically shorter than shown. Use these as a benchmark, not a prediction.

Small-cohort warning (< 30):

Fewer than 30 comparable trials were found. Treat these numbers as indicative only.

Very small cohort (< 10):

Very few comparable trials exist. Percentiles are unreliable at this size.

Limitations

  • Duration includes follow-up. Duration runs from study start to primary completion, which covers treatment and follow-up as well as enrollment. Enrollment rates derived from it are therefore a lower bound on the true recruitment rate (§5.3), and forecasts are correspondingly conservative.
  • Per-country enrollment is not available. ClinicalTrials.gov publishes a trial's total enrollment, not enrollment by country. A trial contributes its overall rate to every country where it lists a site, so country rows describe the trials active in a country rather than recruitment performance within it.
  • City figures describe trials, not sites. ClinicalTrials.gov publishes no enrollment, dates or outcomes per site, and places every site at its city centre. City rows count the trials that listed a site in the city; a termination share reflects why those trials stopped, which is often unrelated to any site (§5.7).
  • MeSH tagging is ClinicalTrials.gov's, not curated. Conditions come from the registry's conditionBrowseModule (algorithmically derived MeSH terms). Mis-tagged or over-broad terms in the source carry through to cohorts here.
  • Terminated trials. Terminated trials are excluded by default. With "Include terminated trials" they enter the rate percentiles only, never the duration histogram, because their duration is truncated (§5.2).
  • Ancestor fallback (levels 3 and 5). When too few trials match the exact term, the cohort uses the nearest non-generic MeSH parent: the ancestor shared by the largest number of cohort trials, excluding top-level categories such as "Diseases" or "Neoplasms" alone (§4). The cohort description on the results page always states when this happened.
  • Counts, not predictions. Percentiles are nearest-rank statistics over registered trials. Registrations with missing actual dates, no listed sites, implausible durations (< 1 or > 240 months) or rates above 50 subjects/site/month are excluded rather than imputed; the excluded counts are tracked in the ETL quality report.

Public data only, from the ClinicalTrials.gov API v2. No accounts, no tracking.