Skip to content

Forecasting Model

Pharmaceutical forecasting domain model: patient flow, addressable population, market share, revenue calculations, and Monte Carlo simulation.

For technical architecture (Jotai state, React Query, component patterns), see ARCHITECTURE.md.

  1. Introduction & Model Overview
  2. Patient Flow Model
  3. Addressable Population Calculations
  4. Market Share Model
  5. Revenue/Sales Calculations
  6. Monte Carlo Simulation
  7. Data Inputs Reference

The Bioloupe forecasting model predicts pharmaceutical sales revenue by modeling the patient journey from disease incidence through treatment and calculating market share capture across multiple therapy lines.

flowchart TB
    A[Annual Incidence] --> B[Addressable Population]
    B --> C[Drug Treated Patients]
    C --> D[Market Share Applied]
    D --> E[New Patients per Therapy Line]
    E --> F[Revenue Calculation]
    F --> G[Total Sales Forecast]
ConceptDescription
IncidenceNew disease cases per year in a population
Addressable PopulationPatients who can potentially receive treatment
Therapy LineSequential treatment stage (1L, 2L, 3L+)
Peak ShareMaximum market share a product can achieve
Loss of Exclusivity (LoE)When patent protection ends and generics enter
Class SharePercentage of patients suitable for the therapy class (adjusts Peak Share)

The model tracks patients through a funnel from initial diagnosis to treatment across multiple therapy lines.

flowchart TD
    subgraph Incidence["Disease Incidence"]
        I[Base Incidence] --> EVO[Incidence Evolution Array<br/>compound growth per year]
    end

    subgraph StageSplit["Stage Split (Solid Tumors Only)"]
        EVO --> |Solid Tumor| ES[Early Stage %]
        EVO --> |Solid Tumor| MS[Metastatic %]
        EVO --> |Hematology| TH[Full Incidence]
        ES --> E2E[+ Early to Early Relapse %]
        ES --> E2M[Early to Met Relapse %]
        E2M --> MS
    end

    subgraph Addressable["Addressable Population (1L)"]
        E2E --> BF1["x Biomarker Factor"]
        BF1 --> EA["Early Addressable x HC%"]
        MS --> BF2["x Biomarker Factor"]
        BF2 --> MA["Met Addressable x HC%"]
        TH --> BF3["x Biomarker Factor"]
        BF3 --> TA["Therapy Addressable x HC%"]
    end

The model handles two main disease categories differently:

CategoryStage Split
Solid TumorsYes — Early Stage + Metastatic
HematologyNo — Full incidence flows to therapy

Disease type is 'Solid Tumor' | 'Hematology' (see DiseaseConfig in src/features/reports/forecasting/types.ts). Specific indications are configured via reference data from the API.

Solid Tumors split into Early Stage and Metastatic populations. Early-stage patients may relapse into the metastatic pool. Hematology cancers are systemic from diagnosis, so all patients flow directly to therapy lines without stage separation.

Formula difference:

Solid Tumor Met Addressable = (Incidence x Met%) + (Incidence x Early% x Relapse%)
Hematology Addressable = Incidence x Healthcare Access%

HA is the scenario-level value from the SummaryPanel, applied once at the top of the patient flow.

flowchart TD
    A[1L Addressable Population] --> |Treatment Rate %| B[1L Drug Treated Patients]
    B --> |Market Share %| C[1L New Patients on Product]

    B --> |Pool minus Retreatment| D[Available for 2L]
    D --> |Transition Rate %| E[2L Drug Treated Patients]
    E --> |Market Share %| F[2L New Patients on Product]

    E --> |Pool minus Retreatment| G[Available for 3L]
    G --> |Transition Rate %| H[3L Drug Treated Patients]
    H --> |Market Share %| I[3L New Patients on Product]

Key Transition Parameters:

  • Drug Treatment Rate: Percentage of eligible patients who receive any treatment (applied at stage entry only — see Important note below)
  • Transition Rate: Percentage of patients who move from one therapy line to the next
  • Retreatment Factor: Patients staying on current therapy (reduces pool for next line)
  • Re-Treatment from Prior Lines Toggle (retreatmentOption, per-line UI): OFF (default) — drug-exposed patients are excluded from subsequent-line pools (pool − newPatients × 1). ON — drug-exposed patients remain in subsequent-line pools and can be captured again by the product’s market share (pool − 0).

Important: Drug Treatment Rate applies at the funnel entry point of each stage group only — the early line, the first metastatic line (Met1), and the first therapy line (hematology 1L). Custom lines participate in their effective stage group (stageCategory ?? "therapy") and are applicable only when they are the first member of that group by array order; in practice that means custom lines appended after a reference line are non-applicable. For non-first lines the UI hides the Drug Treatment Rate field and shows an inline note pointing back at the source line, and the compute/report/MC/Tornado paths skip DTR (factor = 1); transitioned patients are considered eligible. Gated by isDTRApplicable() in src/features/forecasting/math/forecasting.ts.


Some cancer indications have biomarker-defined sub-populations — subsets of patients who test positive for a specific biomarker. For example, Oropharyngeal Cancer has an HPV Positive sub-population representing ~68% of cases.

When a biomarker filter is active, the model applies a funnel multiplier to the first-line addressable population. Biomarker does not modify incidence — it filters within the patient flow funnel.

biomarkerFactor = (biomarkerPercentage / 100) x (biomarkerTestingRate / 100) [when active]
= 1.0 [when inactive]

Applied in the funnel:

Addressable Population (incidence + relapse -- unchanged by biomarker)
|
x biomarkerFactor <- applied here (first line only)
|
x Healthcare Access %
|
x Neo/Adj Factor (if applicable)
|
x Drug Treatment Rate
|
= Drug Treated Patients
  • Biomarker Percentage: prevalence of the biomarker in the disease population (e.g., 68%)
  • Testing Rate: percentage of patients actually tested for this biomarker (e.g., 93%)

Example: HPV Positive, Oropharyngeal Cancer (USA)

Section titled “Example: HPV Positive, Oropharyngeal Cancer (USA)”
ParameterValue
Addressable Population15,004
HPV Positive Prevalence68%
HPV Testing Rate93%
biomarkerFactor0.68 x 0.93 = 0.6324
Filtered Addressable15,004 x 0.6324 = 9,489
  • Biomarker filtering is optional (toggle OFF by default)
  • Only one biomarker can be active per indication at a time
  • Applied to first lines only (Early 1L, Met 1L, Therapy 1L for hematology); subsequent lines inherit the filtered count via transition rates
  • Toggling OFF sets factor to 1.0 (no filtering)
  • Incidence count is never modified — biomarker is a derived multiplier via biomarkerFactorAtom

Key files:

  • Atoms: biomarkerFactorAtom, activeBiomarkerAtom, toggleBiomarkerAtom in src/features/reports/forecasting/atoms.ts
  • Computation: biomarkerFactor param on calculateLineFunnelForDisplay, calculateLinePatients, calculateLineSales, calculateTreatmentEligiblePatients
  • UI: FirstLineExtras component in LinePanel.tsx

The biomarker testing rate can project year-over-year via the “Expected to change?” toggle. When enabled, the testing rate compounds annually starting from startYear, capped at max:

testingRate(year) = testingRate(year - 1) x (1 + min/100) for year >= startYear
= min(testingRate(year), max) clamped each step
= baseTestingRate for year < startYear

The biomarker factor at a given year becomes:

biomarkerFactor(year) = (biomarkerPercentage / 100) x (testingRate(year) / 100)

Growth is shared across early and metastatic first lines where the same biomarker is active (one config per bm:{biomarkerId} instance row). Computed via computeBiomarkerFactor(year, percentage, testingRateBase, growth) in src/features/forecasting/math/forecasting.ts.


2.1.1 Transplant Eligibility Split (Hematology — AML / MM / DLBCL / FL / Hodgkin Lymphoma)

Section titled “2.1.1 Transplant Eligibility Split (Hematology — AML / MM / DLBCL / FL / Hodgkin Lymphoma)”

For hematologic malignancies where stem cell transplant is a conditioning step, a therapy line may forecast only a slice of the addressable population: the transplant-eligible cohort, the non-eligible cohort, or some other defined subgroup. The user picks one cohort per line and sets a single percentage applied as a multiplier (mirrors the Neo/Adj split pattern).

transplantSplitFactor = transplantSplitPercent / 100 [when toggle ON for line L]
= 1 [when toggle OFF]
  • When disabled or not allowlisted, factor = 1.0 (no effect).
  • The transplantSplitSetting ("eligible" / "non-eligible" / "other") is a label — does not change the math, but drives the smart default.

When the toggle flips ON (and the user hasn’t manually overridden the percent), the percent auto-fills from the disease-level transplantEligible reference value E:

SettingAuto-fill default
eligibleE (or 50 if no reference)
non-eligible100 − E (or 50 if no reference)
other100
Addressable Population
|
x Healthcare Access
|
x Neo/Adj Factor (early-stage solid tumors only)
|
x Transplant Split Factor <- NEW, per line, hematology only
|
x Drug Treatment Rate
|
= Drug Treated Patients

Applied per-line in calculateLinePatients and calculateLineFunnelForDisplay via getTransplantSplitFactor(line).

  • Allowlist: AML, MM, DLBCL, FL, Hodgkin Lymphoma (case-insensitive). Block hidden for all other indications.
  • Toggle: User enables/disables the split per line.
  • Segmented control: Eligible | Non-eligible | Other.
  • Percentage input: Single percentage (0–100%) applied as a multiplier (default = setting-aware as above).
  • No Monte Carlo / Tornado wiring: the percent row hides the “Add to Monte Carlo” toggle.

Key files:

  • Type: UnifiedLineData.transplantSplitEnabled / transplantSplitSetting / transplantSplitPercent in src/features/reports/forecasting/types.ts
  • Computation: getTransplantSplitFactor() + getTransplantSplitDefault() in src/features/reports/forecasting/sections/Configuration/transplant.ts
  • UI: Inline block in LinePanel.tsx (gated on isTransplantAllowlisted(indication) next to the Treatment Rate row)
  • DOCX: single-emission block in reporting/collectReportData.ts (mirrors neoAdj)

2.2 Neoadjuvant / Adjuvant Split (Early Stage Solid Tumors Only)

Section titled “2.2 Neoadjuvant / Adjuvant Split (Early Stage Solid Tumors Only)”

For early-stage solid tumors, patients may receive treatment in different settings:

  • Neoadjuvant = treatment before surgery (shrink the tumor, then operate)
  • Adjuvant = treatment after surgery (operate, then treat to prevent recurrence)
  • Other = other treatment settings (e.g. maintenance, palliative)

The user selects one category and sets a single percentage that acts as a simple multiplier on the eligible population.

neoAdjFactor = neoAdjPercent / 100
  • When disabled or not applicable, factor = 1.0 (no effect)
  • The selected category (neoAdjSetting) is a label only — does not affect the math

Default: 100% = factor 1.0 (no reduction)

Addressable Population
|
x Healthcare Access
|
x [Setting] Split <- neoAdjPercent / 100
|
x Drug Treatment Rate
|
= Drug Treated Patients

The neo/adj factor is applied before drug treatment rate (see calculateLineFunnelForDisplay in src/features/forecasting/math/forecasting.ts). The toggle is per-line — any early-stage line can have it enabled independently. In sales/Monte Carlo calculations, the factor applies to the first line only.

  • Toggle: User enables/disables the split per line
  • Segmented control: Pick one of 3 categories: Neoadjuvant, Adjuvant, or Other
  • Percentage input: Single percentage (0-100%) applied as a multiplier (default 100%)
  • Rollback: Resets percentage to 100%

Key files:

  • Type: UnifiedLineData.neoAdjSetting and neoAdjPercent in src/features/reports/forecasting/types.ts
  • Computation: getNeoAdjFactor() in src/features/forecasting/math/forecasting.ts
  • UI: Segmented control + single percentage in LinePanel.tsx

2.3 Custom Variables (Addressable Population Multipliers)

Section titled “2.3 Custom Variables (Addressable Population Multipliers)”

Users can define up to MAX_CUSTOM_VARIABLES custom multiplier variables per therapy line (see constants.ts). Each custom variable represents a percentage applied multiplicatively to the addressable population, allowing users to model additional filtering factors not covered by the built-in parameters.

customMultiplier = product(customVar.value / 100) -- product of all custom variable percentages

Applied in the patient funnel (order matches code):

Addressable Population
|
x Healthcare Access %
|
x Neo/Adj Factor (if applicable -- see section 2.2)
|
x Drug Treatment Rate
|
x customMultiplier <- product of all custom variable percentages
|
= Drug Treated Patients

First-line (1L) custom variables are synced to incidenceVars.customVariables for use in Monte Carlo and Tornado simulations. This ensures probabilistic analyses include the custom variable effects.

For 2L+ lines, the system uses findMatchingLineCustomVariable() to locate matching custom variables across lines, enabling consistent filtering across the therapy cascade.

Key files:

  • Computation: getCustomMultiplier() (display paths — year-agnostic) and getCustomMultiplierForYear(vars, year) (live Model/SalesChart and calculateTreatmentEligiblePatients — compounds via applyChangeableValue when changeable && startYear !== null) in src/features/forecasting/math/forecasting.ts
  • Matching: findMatchingLineCustomVariable() in src/features/forecasting/math/forecasting.ts
  • Constant: MAX_CUSTOM_VARIABLES in src/features/reports/forecasting/constants.ts

Growth Projection (“Expected to change?”)

Section titled “Growth Projection (“Expected to change?”)”

Custom variables already carry {changeable, min, max, startYear} and project year-over-year via applyChangeableValue (src/features/forecasting/math/forecasting.ts). The same growth mechanism is now wired for built-in Healthcare Access (scenario-level), Drug Treatment Rate (per-line), and Biomarker Testing Rate (per-biomarker) — see §2.1 Testing Rate Over Time and §3 Healthcare Access.


Per-year incidence is read from the incidenceEvolution array, which is built by compounding the per-year growth rate off the base:

incidence[0] = round(base)
incidence[i] = round(incidence[i-1] x (1 + growthRate / 100))

Example:

  • Base Incidence (year 0): 50,000 patients
  • Growth Rate: 0.5% per year
incidence[6] = round(50,000 x (1 + 0.005)^6) ~= 51,515 patients

The array is built by buildIncidenceEvolution() in src/features/forecasting/math/incidence-evolution.ts and consumed year-by-year in src/features/forecasting/math/forecasting.ts via incidenceEvolution[yearIndex]. Growth rates can be edited per transition; see the Evolution Arrays section for storage details. Geo-level variation is applied upstream at cascade time via the per-capita incidenceBase fallback.

Addressable Population by Disease Category

Section titled “Addressable Population by Disease Category”

Solid tumors split into Early Stage and Metastatic populations with relapse transitions:

Early Addressable:

Early Addressable = Incidence x EarlyStage% x (1 + EarlyToEarlyRelapse%) x HealthcareAccess%

Metastatic Addressable:

Met Addressable = (Incidence x MetStage% + Incidence x EarlyStage% x EarlyToMetRelapse%) x HealthcareAccess%

Code note: computeAddressable() returns raw stage addressable counts. The shared buildLineFunnelMap() helper in src/features/forecasting/math/forecasting.ts multiplies the population-level Healthcare Access into stage addressable before invoking calculateLineFunnelForDisplay, so the funnel function itself no longer applies HA per line. Both PatientFlowTimeline (the patient-flow tree UI) and LinePanel (the configuration panel) consume that helper, so their addressable/eligible counts cannot drift. The sales path (calculateLinePatients) reads HA from the population row via the populationHealthcareAccess param and applies it once at the first line via applyFilters.

Example (Breast Cancer):

  • Incidence: 50,000
  • Early Stage: 70%
  • Metastatic: 30%
  • Early to Early Relapse: 15%
  • Early to Met Relapse: 10%
  • Healthcare Access: 95%
Early Addressable = 50,000 x 0.70 x (1 + 0.15) x 0.95 = 38,238 patients
Met Addressable = (50,000 x 0.30 + 50,000 x 0.70 x 0.10) x 0.95 = 17,575 patients

Hematology indications do not have stage splits:

Addressable = Incidence x HealthcareAccess%
ParameterDescription
Early Stage %Initial early-stage diagnosis
Localized %Sub-breakdown: localized early stage (optional)
Locally Advanced %Sub-breakdown: locally advanced (III-IVB by default; III-IIIB for lung and breast, optional)
Metastatic %Initial metastatic diagnosis (derived: 100 - Early%)
Unknown Stage %Patients with unknown stage at diagnosis (optional)
Early to Early Relapse %Early patients who relapse but stay early
Early to Met Relapse %Early patients who progress to metastatic

Actual values are sourced per indication from the API reference data.

Note: Early Stage % + Metastatic % + Unknown Stage % = 100%. When Unknown Stage % is not provided, it auto-derives as the residual: max(0, 100 − Early% − Met%).

Early Stage Sub-Variables (Solid Tumors Only)

Section titled “Early Stage Sub-Variables (Solid Tumors Only)”

Some solid tumors have granular staging data that breaks Early Stage into Localized and Locally Advanced (III-IVB) sub-populations. These sub-variables are display-only — the computation layer always uses the combined earlyStagePercent.

Behavior matrix:

Data AvailableParent (Early Stage %)LocalizedLocally AdvancedExample
Both subsRead-only (auto-sum)EditableEditableOropharyngeal: 15.8 + 72.9 = 88.7%
Parent + one subEditableRead-onlyRead-onlyPartial reference data
Parent onlyEditableHiddenHiddenMost indications (today’s behavior)
No dataFallback chainHiddenHiddenMissing geo-specific data

Auto-computation: When both sub-variables are present, the parent is derived:

earlyStagePercent = earlyStageLocalizedPercent + earlyStageLocallyAdvancedPercent
metStagePercent = 100 - earlyStagePercent

Reset behavior: Resetting a sub-variable also resets the parent and metastatic percentage to maintain consistency.

Healthcare access percentages are sourced per geography and indication from the API reference data. The fallback default is DEFAULT_INCIDENCE_DATA.healthcareAccess in src/features/reports/forecasting/constants.ts.

Healthcare Access (scenario-level) and Drug Treatment Rate (per-line) can each project year-over-year via the “Expected to change?” toggle. When enabled, the value compounds annually starting from startYear, capped at max:

v(year) = v(year - 1) x (1 + min/100) for year >= startYear
= min(v(year), max) clamped each step
= baseValue for year < startYear

Implemented by applyChangeableValue(year, baseValue, config) in src/features/forecasting/math/forecasting.ts. The growth config is a ChangeableConfig ({changeable, min, max, startYear}) defined in types.ts.

  • Scenario-level scope (HA): Healthcare Access is a single scenario-level value. It is applied once at the top of the patient flow — multiplied into the stage addressable population before any line-level math runs. Subsequent therapy lines no longer apply HA. The user edits HA in the SummaryPanel (Patient Flow summary), and a single healthcareAccessGrowth row stores the optional “Expected to change?” projection.
  • Per-line scope (TR): Drug Treatment Rate remains per-line, with a per-line growth config; absent rows = growth OFF.
  • Per-line override (HA): To model line-specific access penalties (e.g., access drops at later therapy lines, regional sub-population effects), users add a Custom Variable on the affected line. Custom variables multiply that line’s eligible pool via getCustomMultiplier. Example: a line with HA penalty 85% is modeled by adding a custom variable named e.g. “Late-line access penalty” with value 85.
  • Persistence: Stored as JSON-encoded ChangeableConfig in instanceRowsAtom under companion row names healthcareAccessGrowth ({line: null}) and treatmentRateGrowth (per-line).
  • Defaults on toggle ON: rate 0%, max 100%, startYear 2030 (DEFAULT_GROWTH_CONFIG in constants.ts).

Peak Share is the absolute maximum share a product reaches over the forecast. It is determined by competitive positioning. The product approaches Peak Share via the Speed-to-Peak uptake curve and may decline before LoE due to market events or competitive dynamics; once LoE hits, share also erodes via the LoE curve. A user-set Peak Share override (via the Custom Input toggle) wins over the Launch Order / Best-in-Class auto-recommendation.

flowchart LR
    subgraph Inputs["Peak Share Inputs"]
        LO[Launch Order<br/>1st to 10th]
        BIC[Best-in-Class?<br/>Yes/No]
        DVC[Delay vs Competition<br/>Quarters]
    end

    subgraph Calculation["Peak Share Calculation"]
        LO --> BASE[Base Share from Matrix]
        BIC --> BONUS[Best-in-Class Bonus]
        DVC --> PENALTY[Delay Penalty]
        BASE --> PS[Peak Share %]
        BONUS --> PS
        PENALTY --> PS
    end

Formulas:

Peak Share (within class):

PeakShare = min(100, max(0, BaseShare[LaunchOrder] + BestInClassBonus - DelayPenalty))

Effective Peak Share (used in market share calculations):

EffectivePeakShare = CustomEffectivePeakShare (if user has toggled Custom Input)
OR PeakShare x (ClassShare / 100) (Bioloupe Guidance -- default)

Where:

  • BaseShare comes from the LAUNCH_ORDERS matrix in constants.ts (indexed by launch order and number of competitors)
  • BestInClassBonus from BEST_IN_CLASS array (indexed by number of competitors)
  • DelayPenalty = DelayQuarters > COMPETITION_THRESHOLD_QUARTERS ? DelayQuarters x COMPETITION_FACTOR_MULTIPLIER : 0 (see constants.ts for current values)
  • ClassShare is the percentage of patients suitable for the therapy class (default 100%)

Example:

  • Launch Order: 2nd to market (with 3 competitors)
  • Best in Class: Yes
  • Delay: 6 quarters behind first entrant
  • Base Share (2nd position, 3 competitors): from LAUNCH_ORDERS
  • Best-in-Class Bonus (3 competitors): from BEST_IN_CLASS
  • Delay Penalty: 6 x COMPETITION_FACTOR_MULTIPLIER

Verify current lookup values in LAUNCH_ORDERS and BEST_IN_CLASS arrays in constants.ts.

Peak Share = 29% + 30% - 3% = 56%

Products don’t achieve peak share immediately. The uptake curve determines how quickly market share ramps up. Peak Share is the ceiling; the uptake curve controls acceleration toward it.

All uptake curves are defined in the UPTAKE_CURVE constant in src/features/reports/forecasting/constants.ts. Curves are named {N} Year {Speed} (e.g., “3 Year Fast”). The number indicates years to reach 100% of peak share.

Note: Curves are named like “3 Year Medium”, “5 Year Fast”, etc. The number indicates years to reach 100% of peak share. A product with 60% peak share using “3 Year Fast” reaches 60% x 60% = 36% in Year 1, 60% x 85% = 51% in Year 2, and the full 60% in Year 3.

flowchart LR
    subgraph PreLaunch["Pre-Launch"]
        PL[Market Share = 0%]
    end

    subgraph Ramp["Ramp Period"]
        PS[Peak Share] --> UC[Uptake Curve Applied]
        UC --> MS1[Growing Share]
    end

    subgraph Mature["Mature Period"]
        MS1 --> PEAK[At Peak Share]
    end

    subgraph PostLoE["Post-LoE"]
        PEAK --> ER[Erosion Curve]
        ER --> FINAL[Declining Share]
    end

    PreLaunch --> Ramp
    Ramp --> Mature
    Mature --> PostLoE

Year N Market Share Formula:

MarketShare[Year] = UptakeCurve[YearOffset] x EffectivePeakShare x (1 - ErosionRate[YearsPostLoE])

Where EffectivePeakShare = CustomEffectivePeakShare (if set) or PeakShare x (ClassShare / 100)

First Year Weighting (for mid-year launches):

WeightedShare = (Uptake[Y1] x (13 - LaunchMonth) + Uptake[Y0] x (LaunchMonth - 1)) / 12
  • Launch date is the first day of the stated month. A launch of 2025-08-01 means Aug 1, 2025; that calendar year contributes 5 months of revenue (Aug–Dec).
  • Year-1 of Speed-to-Peak is the 12-month rolling window starting at the launch datenot the launch calendar year. The WeightedShare formula above prorates the launch calendar year so the rolling-12-month uptake reads correctly across the launch-year / first-full-year boundary.
  • Year-0 (pre-launch row) market share is 0; the forecast horizon shows one pre-launch year of zeros to make the launch transition visible.
  • Per-input clamp, not cross-line: every per-line market-share input is clamped 0–100% on entry, but the cross-line sum (Σ shares across all selected therapy lines) is intentionally not clamped — that surface lets users model competitive scenarios where lines compete for share.

A market event is a one-time, persistent share adjustment that begins at the event’s date. Events are intentionally additive — not multiplicative — because they model discrete competitive or commercial inflections (a new competitor, a label expansion, a guideline change) rather than scaling the existing uptake.

MarketShare[Year] = ( UptakeCurve[YearOffset] x EffectivePeakShare + EventImpact[Year] ) x LoEImpact[Year]
EventImpact[Year] = sum over all events whose startDate ≤ Year of:
event.impactPercent x UptakeCurve[Year − event.year] (same Speed-to-Peak as the line)

Behavior:

  • Events use the same Speed-to-Peak uptake curve as the line they sit on, so a 5pp event under “3 Year Slow” ramps to its full 5pp impact over 3 years.
  • Multiple events stack additively (their impacts sum into a single EventImpact[Year]).
  • Negative impacts are allowed (e.g., a competitor launch with impactPercent: -10 subtracts 10pp).
  • Events are permanent — there is no end date. Once active, an event continues to contribute its impact for the remainder of the forecast.
  • Events are gated by the line’s isSelected flag; deselected lines contribute 0 to both share and event impact.
  • Event impact is added on top of ramped share, then multiplied by LoEImpact, so post-LoE erosion attenuates the event’s contribution alongside the base share.

After LoE, market share erodes due to generic/biosimilar competition. Small molecules face rapid generic erosion; biologics erode more slowly due to biosimilar complexity.

Erosion curves are defined in MOLECULE_SHARE_EROSION and BIOLOGICS_SHARE_EROSION in src/features/reports/forecasting/constants.ts. Index i of the curve is the erosion percentage for the ith calendar year post-LoE (i = 0 is the year before LoE = 0% erosion; i = 1 is the LoE year; i ≥ N plateaus at the last tabulated value).

LoE Year Weighting (for mid-year LoE) — calendar year Y containing the LoE month M is split into a pre-LoE segment (months 1..M−1) and a post-LoE segment (months M..12):

LoEImpact[Y] = 1 - ( Erosion[i] x (13 - M) / 12 // post-LoE segment
+ Erosion[i-1] x (M - 1) / 12 ) // pre-LoE segment

where i = Y - LoEYear + 1 (so for Y = LoEYear, i = 1 → reads Erosion[1] for post-LoE months and Erosion[0] = 0 for pre-LoE months, yielding partial-year erosion). Years prior to LoE return LoEImpact = 1.0.

  • Jan-1 LoE = full calendar year of erosion. A loeDate of 2037-01-01 makes the entire year 2037 post-LoE — Jan 1 is the first post-LoE day, so M = 1 and the formula degenerates to 1 - Erosion[1].
  • Mid-year LoE prorates erosion via the partial-year blend formula above (months 1..M−1 use the prior-year erosion Erosion[i-1]; months M..12 use the LoE-year erosion Erosion[i]).
  • No early erosion before LoE. For any calendar year Y < LoEYear, the LoE pipeline short-circuits to LoEImpact = 1.0 (no share loss).
  • Erosion plateaus past the tabulated curve. The curves in MOLECULE_SHARE_EROSION / BIOLOGICS_SHARE_EROSION are 21 entries long; for any year more than 20 years past LoE the lookup clamps to the final tabulated value (no extrapolation).
  • Past-LoE drugs are out of scope. The LoE date picker disables past calendar years (minYear = currentYear on the MonthPicker) — the model is for products with at least their LoE still ahead.
  • Erosion applies to share, not price. The Net Price Over Time computation has no LoE term; the price formula continues unchanged across the LoE boundary.

flowchart LR
    NP[New Patients] --> CALC((x))
    PRICE[Net Price per Month] --> CALC
    CALC --> CALC2((x))
    COMP[Compliance %] --> CALC2
    CALC2 --> CALC3((x))
    MOT[Months of Therapy] --> CALC3
    CALC3 --> DIV[/ 1,000,000]
    DIV --> SALES[Line Sales $M]

Price typically changes annually from the launch price:

NetPrice[Year] = LaunchPrice x (1 + AnnualPriceChange%)^(Year - LaunchYear)

Net price is independent of LoE. The price formula continues unchanged post-LoE — LoE only erodes share. If the price is meant to drop after generic entry, the user expresses that via the annualNetPriceChange schedule itself, not via the LoE pipeline. Likewise, the launch year sets the baseline (Year ≤ LaunchYear → NetPrice = LaunchPrice); price changes start compounding from year 1 onward.

Example:

  • Launch Price: $15,000/month
  • Annual Price Change: -2%
  • Year of Launch: 2025
YearCalculationNet Price
2025$15,000 x (0.98)^0$15,000
2026$15,000 x (0.98)^1$14,700
2027$15,000 x (0.98)^2$14,406
2030$15,000 x (0.98)^5$13,537
LineSales ($M) = sum(CohortPatients[yearOffset] x NetPrice x Compliance% x MonthsThisYear[yearOffset]) / 1,000,000

MonthsOfTherapy is the total time a typical patient stays on the drug — not a protocol length, not a treatment-cycle length. A value of 24 means the average patient is on the drug for 24 months; revenue from that cohort spans 24 months of billing.

MonthsOfTherapy is distributed across calendar years using cohort logic — each cohort year contributes up to 12 months. For example, a 24-month therapy generates 12 months of revenue in year 0 (from that year’s new patients) and 12 months in year 1 (from the prior year’s cohort). When MonthsOfTherapy <= 12, only one cohort year contributes and the formula reduces to NewPatients x NetPrice x Compliance% x MonthsOfTherapy / 1M.

Example (1L Therapy):

  • New Patients: 5,000
  • Net Price: $12,000/month
  • Compliance: 85%
  • Months of Therapy: 10
LineSales = 5,000 x $12,000 x 0.85 x 10 / 1,000,000 = $510M
TotalSales = Sum of LineSales across all therapy lines (1L + 2L + 3L...)

Scenario: Oncology product, 2nd line therapy, Year 3 post-launch

InputValue
Addressable Population30,000
Treatment Rate80%
Transition Rate (from 1L)60%
Market Share (Year 3)35%
Net Price$14,000/month
Compliance90%
Months of Therapy8

Calculation:

1. Eligible Pool = 30,000 - 1L_Patients retained
(Assume 15,000 available after 1L retention)
2. 2L Eligible = 15,000 x 60% x 80% = 7,200 patients
3. New Patients = 7,200 x 35% = 2,520 patients
4. Sales = 2,520 x $14,000 x 0.90 x 8 / 1,000,000 = $254M

Monte Carlo simulation quantifies forecast uncertainty by running thousands of scenarios with randomly sampled variable values and aggregating the distribution of outcomes.

The simulation count is configurable and persisted per-scenario. Results are isolated between scenario tabs via independent Jotai stores. For execution architecture (worker lifecycle, dual-path execution, auto-run/stale logic, store pinning), see ARCHITECTURE.md.

flowchart TD
    subgraph Inputs["Variable Definitions"]
        V1["Variable 1<br/>Min: 10 | Mode: 15 | Max: 25"]
        V2["Variable 2<br/>Min: 50 | Mode: 70 | Max: 80"]
        VN["Variable N<br/>Min: 5 | Mode: 8 | Max: 12"]
    end

    subgraph Simulation["Monte Carlo Engine"]
        V1 --> SAMPLE[Sample from Distributions]
        V2 --> SAMPLE
        VN --> SAMPLE
        SAMPLE --> |"1K-100K iterations"| MODEL[Run Full Sales Model]
        MODEL --> COLLECT[Collect Results]
    end

    subgraph Outputs["Analysis Outputs"]
        COLLECT --> DIST[Distribution of Outcomes]
        COLLECT --> PERC["Percentiles<br/>P10 / P50 / P90"]
        COLLECT --> TORNADO[Tornado Chart]
    end

Variables that can be simulated:

CategoryVariables
IncidenceEarly Stage %, De Novo %, Unknown Stage % (when >0), Early-to-Early Relapse %, Early-to-Met Relapse %
TreatmentHealthcare access, Treatment rate — for solid tumors this splits into Early Treatment Rate (first early-stage line) and Metastatic Treatment Rate (first met-stage line) when those stages are present; hematology and early-only / met-only solid tumor scenarios fall back to a single Treatment Rate. See deriveDefaultSimulationVariables.ts.
MarketPeak share (per line), Months of therapy (per line)
PricingLaunch price
CustomCustom multiplier variables (up to MAX_CUSTOM_VARIABLES per line, when changeable)

Note: Transition rate, Compliance, and Annual price change are not currently simulated.

Incidence Evolution: Monte Carlo and Tornado analyses use the evolved incidence values from Custom Inputs. For each simulated year, getYearIncidence(year) applies the growth rates defined in the Incidence Evolution table.

DistributionUse CaseParameters
TriangularMost common — when you have min/max/modeMin, Mode, Max
NormalSymmetric uncertaintyMean, Std Dev
UniformEqual probability across rangeMin, Max

The tornado chart ranks variables by their impact on forecast uncertainty:

For each variable:
1. Hold all other variables at most likely values
2. Run model at variable's minimum value -> Low result
3. Run model at variable's maximum value -> High result
4. Impact = |High - Low|
5. Rank variables by impact (largest at top)

Variables at the top of the tornado have the greatest influence on uncertainty. Focus validation efforts on high-impact variables.

PercentileMeaning
P1010% chance results will be lower (pessimistic)
P50Median outcome (50% above, 50% below)
P9090% chance results will be lower (optimistic)

Key files: src/features/reports/forecasting/sections/MonteCarlo/


Each indication requires a DiseaseConfig — see src/features/reports/forecasting/types.ts. Includes disease type, incidence, stage mix (for solid tumors), and retreatment factor. Fallback values in STAGE_MIX_DEFAULTS (src/features/reports/forecasting/constants.ts).

The initialization flow is geo-aware: each geography derives its own baseIncidence, growthRate, healthcareAccess, stage mix, and treatment line configuration from the API data. When geo-specific data is unavailable, the fallback chain is: Requested geo -> USA -> EU5 -> Japan -> field-level defaults.

When the selected geography lacks its own incidenceBase for a cancer, the fallback cascade sources the value from another geo (USA -> EU5 -> Japan). To preserve epidemiological plausibility (USA’s raw patient count is ~2.8x Japan’s because the USA population is ~2.8x larger), the sourced value is normalized per-capita using each geography’s popBase:

finalBase = round(sourceBase x targetPopBase / sourcePopBase)

Example: Japan has no CRC incidenceBase. Cascade sources USA (154,270). USA popBase = 344.1M; Japan popBase = 124.4M. Result: round(154,270 x 124.4 / 344.1) ~= 55,773.

The derivation is surfaced in the amber fallback banner tooltip at the top of the Configuration section (2-line sub-block showing method and formula).

Degraded path: if either popBase is missing, the raw source value is copied 1:1 without per-capita normalization (today’s pre-fallback behavior).

Scope: applies to incidenceBase only. incidenceGrowth fallback copies 1:1 — growth rates are already cancer/geo-specific percentages that embed all epidemiological factors.

Stage mix defaults: see STAGE_MIX_DEFAULTS in src/features/reports/forecasting/constants.ts. Line parameter defaults (treatment rate, compliance, months of therapy): see LINE_DEFAULTS in constants.ts and the DEFAULT object in src/features/reports/forecasting/sections/Configuration/atoms.ts.

When stage distribution includes an “Unknown” category, the unknown portion is redistributed proportionally across known stages:

knownTotal = earlyStagePercent + metStagePercent
effectiveEarlyPct = earlyStagePercent / knownTotal
effectiveMetPct = metStagePercent / knownTotal
earlyIncidence = totalIncidence x effectiveEarlyPct
metIncidence = totalIncidence x effectiveMetPct

Example: Incidence=154,270, Early=71%, Met=23%, Unknown=6%

knownTotal = 71 + 23 = 94
effectiveEarly = 71/94 = 75.53% -> 116,523 patients
effectiveMet = 23/94 = 24.47% -> 37,747 patients
Total accounted: 154,270 (100%)

When unknownStagePercent = 0 (default), knownTotal = 100% and the formula reduces to the identity — no change from direct percentage usage.

Unknown is logically a child of Early Stage. The UI constraint earlyStagePercent + metStagePercent + unknownStagePercent = 100% is enforced as:

  • When Unknown changes: earlyStagePercent = max(0, 100 - metStagePercent - unknownStagePercent) (Early absorbs the change; Met stays fixed). Sub-variables (Localized, Locally Advanced) scale proportionally.
  • When Early changes: metStagePercent = max(0, 100 - earlyStagePercent - unknownStagePercent) (Met absorbs the change; Unknown stays fixed).

Projection length is PROJECTION_DATA_LENGTH = 21 in constants.ts1 pre-launch year (zeros) + 20 post-launch years. The pre-launch row anchors the chart at firstSelectedLaunchYear − 1 so the launch transition is visible; the 20 post-launch years are the active forecast horizon. Default visible range is DEFAULT_VISIBLE_LABELS in SalesChart.tsx. Data points are annual.

Launch year flexibility. The launch picker accepts any calendar year — past (already-launched products), present, or future. The LoE picker is the only date input that disables past years; LoE is bound to firstSelectedLaunch + marketExclusivityYears bidirectionally — editing LoE shifts all line launches by the same month delta (see ARCHITECTURE.md §“bidirectional sync (loeDate)”).

Evolution Arrays (incidenceEvolution, netPriceEvolution)

Section titled “Evolution Arrays (incidenceEvolution, netPriceEvolution)”

Both are stored per {geo, indication} in instanceRows as number[] of length PROJECTION_DATA_LENGTH — always arrays, never collapsed to a scalar even when values are identical.

  • Fresh load: derived client-side, not returned by the API.
    • incidenceEvolution = buildIncidenceEvolution(baseIncidence, growthRate, PROJECTION_DATA_LENGTH) (inputs from statistics geoConfig).
    • netPriceEvolution = [0, 0, ...]. When the toggle is enabled, pre-filled with annualNetPriceChange; when disabled, reset to [].
  • Saved model load: both arrive as arrays inside FullSnapshot.instanceRows (GET /api/forecasting/:id).
  • Related scalar: annualNetPriceChange is a separate per-{geo, indication} row (default 2 from DEFAULT_ASSUMPTION_DATA), overridden by the API only on saved-model load.
  • Companion slot — incidenceEditedYears: sorted number[] of transition indices (0-based, between consecutive evolution years) the user has explicitly edited. Hydrated to ReadonlySet<number> on read and threaded through applyGrowthRateChange so non-edited downstream cells snap to referenceRate while user-edited cells are preserved. resetGrowthRateAtYearAtom drops the rolled-back yearIndex from the set; resetAllGrowthRatesAtom clears it entirely. Legacy snapshots without the slot load as an empty Set. Invariant: user-edited is explicit, not derived — the app never infers edits from value comparison.

Common gotchas:

  • The geo fallback chain is: Requested geo -> USA -> EU5 -> Japan -> field-level defaults (per-capita normalization applied to incidenceBase when the cascade fires)
  • Healthcare access percentages are API-sourced per indication, not hardcoded
  • Unknown stage redistribution preserves total incidence by proportionally scaling early/met percentages
  • Compliance default is defined in Configuration/atoms.ts (not 100% — verify current value there)