Skip to content

Refactor PhenoAge calculator to use config-driven model#7

Open
ajsteele wants to merge 41 commits intomainfrom
claude/refactor-phenoage-config-KEFPW
Open

Refactor PhenoAge calculator to use config-driven model#7
ajsteele wants to merge 41 commits intomainfrom
claude/refactor-phenoage-config-KEFPW

Conversation

@ajsteele
Copy link
Copy Markdown
Owner

Summary

Refactored the PhenoAge biological age calculator from a hardcoded implementation to a flexible, configuration-driven architecture. The calculator now loads biomarker definitions, unit conversions, and model parameters from external CSV and JSON files, making it easier to update calculations and support different aging models in the future.

Key Changes

  • Configuration Loading: Added loadConfig() function that fetches and parses three configuration files:

    • config/tests.csv: Biomarker definitions (test_id, name, canonical_unit)
    • config/conversions.csv: Unit conversion factors for each biomarker
    • config/models/phenoage.json: Complete model definition including coefficients, transforms, and calculation constants
  • Unit Conversion Engine: Implemented toCanonical() and fromCanonical() functions to handle bidirectional unit conversions, replacing the hardcoded conversion arrays

  • Transform System: Added applyTransform() function to support biomarker-specific transformations:

    • log: Natural logarithm (for CRP)
    • percentage_of:test_id: Compute percentage relative to another biomarker (for lymphocytes as % of WBC)
  • Model Calculation: Extracted mortality model formula into calculateMortalityModel() function with configurable constants, enabling support for different aging models

  • Data Structure Refactoring:

    • Replaced hardcoded tests array with dynamically built formTests array
    • Separated test definitions, conversions, and model parameters into distinct data structures
    • Updated calculation logic to work with canonical units internally, converting to/from model units as needed
  • Code Quality: Improved code organization with clear sections (config loading, conversions, transforms, model calculation, form generation), better variable naming, and consistent formatting

Notable Implementation Details

  • The calculator maintains backward compatibility with URL anchor-based result sharing
  • Canonical (SI) units are used internally for all calculations to ensure consistency
  • The model definition includes optional transform fields on biomarkers for complex calculations
  • Configuration files use simple CSV format for easy maintenance and updates
  • Error handling added for missing configuration files with user-friendly error messages

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh

Pull hard-coded test definitions, unit conversions, and model
coefficients out of phenoage.js into separate config files:

- config/tests.csv: test catalog with SI canonical units
- config/conversions.csv: unit conversion factors (multiply to get SI)
- config/models/phenoage.json: PhenoAge model definition with
  coefficients, constants, and transform declarations

The JS engine now loads these at startup via fetch(), converts user
input to canonical SI units, then to whatever unit the model expects,
applies transforms (log, percentage_of), and multiplies by coefficients.
This architecture supports adding new biological age models by dropping
in a new JSON file without changing any code.

The lymphocyte/WBC dependency is now declared in config as
"percentage_of:wbc" instead of relying on array index arithmetic.

Verified: produces identical results to the original code.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
Replace manual age entry with date of birth and test date fields.
Age is now calculated automatically as fractional years, which is
more precise and sets up the foundation for multi-timepoint tracking.
Test date defaults to today.

Add input validation:
- CRP must be > 0 (required for log transform, prevents -Infinity)
- Non-numeric values flagged with error styling and messages
- Test date must be after date of birth
- All fields required before calculation runs

Add a shareable social media card (canvas element) that shows only
biological age, chronological age, and acceleration — no biomarker
values, preserving privacy. Includes download button and native
Web Share API integration for mobile sharing. Card is colour-coded
green (younger) or amber (older).

URL anchor format updated to store dob and testdate instead of age.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
Old-format URLs (with age=34,years) caused values to shift by one
position because the age entry landed in the tests array and the
restore code mapped by array index. Now matches each saved value
to its form field by test_id, which also handles reordered or
missing entries gracefully.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
…nity

- extractValuesFromAnchor now skips the age= entry from old-format URLs
  and sets an isLegacy flag
- Shows an informational alert on the DOB field when loading a legacy URL,
  explaining the user needs to enter DOB and test date
- Guards result display against Infinity/-Infinity (isNaN misses these)
- Adds .input-alert CSS class for reusable amber info boxes on form fields
- Updates readme example URL to new DOB/testdate format

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
@nopara73
Copy link
Copy Markdown

LGTM, I see CRP didn't trip the AI off (or at least not in an obvious way)

claude and others added 25 commits March 19, 2026 07:39
Two-tier validation for biomarker inputs:
- Amber warning: value outside normal clinical range (still calculates)
- Red error: value outside plausible physiological range (blocks calculation)

Ranges are defined in config/tests.csv (canonical units) and converted to
display units at runtime. analysis/generate_ranges.py documents how ranges
were derived from clinical references and checks whether normal values in
one unit could be confused for another unit.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
Computes normal (2.5th-97.5th percentile) and plausible (0.1th-99.9th,
widened by clinical extremes, tightened by unit-confusion analysis)
ranges directly from the NHANES III training population.

The Python script is retained as a reference but this R version is the
canonical source — run it with: Rscript analysis/generate_ranges.R

Requires: devtools::install_github("dayoonkwon/BioAge")

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
…n report

- CRP log transform now floors at 0.22 mg/dL (NHANES III detection limit)
  to prevent extrapolation beyond training data for modern hs-CRP assays
- R script computes LOESS-smoothed age-stratified medians (20-84) for
  each biomarker, written to config/defaults.csv
- New RMarkdown version (generate_ranges.Rmd) produces visual report with
  histograms per biomarker and age-trend scatterplots with LOESS overlay
  for sanity-checking the smoothing
- "Fill missing with population averages" button uses age-interpolated
  NHANES III medians, with visual distinction for defaulted values
- CSV download of entered values and results
- localStorage auto-saves last entry, restores on return visits
- Added alkaline phosphatase µkat/L unit (Scandinavian/European labs)

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
1 µkat = 10⁻⁶ mol/s, 1 U = 1 µmol/min = 10⁻⁶/60 mol/s
Therefore 1 µkat = 60 U. The previous value (16.6667) was the
nkat-to-U conversion, not µkat-to-U.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
- config/overrides.csv: manual range overrides applied last (CRP
  plausible_low = 0.1 to accommodate hsCRP results)
- generate_ranges.R: converted to knitr::spin format so one file
  serves both as headless script (Rscript) and rendered report
  (knitr::spin). Report-only chunks use eval=is_spinning.
- generate_ranges.Rmd: removed (superseded by spin-compatible .R)

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
dirname(NULL) errors in newer R. sys.frame(1)$ofile doesn't exist
when running via knitr::spin, so use tryCatch and fall back to
getwd()-based detection instead of branching on is_spinning.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
Unit-confusion tightening caps CRP plausible_high at ~18 mg/L because
a normal mg/dL value (0.02-0.2) entered as mg/L falls in the plausible
range after 10x conversion. But high CRP (50-300+ mg/L) is real in
severe sepsis, and the confusion direction only produces falsely LOW
values, so high-end tightening is counterproductive. Manual override
restores 500 mg/L ceiling.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
The clinical_plausible list was spitballed values that dominated the
NHANES-derived 0.1th/99.9th percentiles, making plausible ranges
effectively hand-picked rather than data-driven. Removed so the
pipeline is now:

1. NHANES 0.1th/99.9th percentiles (data-driven)
2. Unit-confusion tightening (algorithmic)
3. Manual overrides from config/overrides.csv (human judgment, documented)

Also removed the CRP plausible_high=500 override that was only needed
to undo the interaction between clinical widening and unit-confusion
tightening.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
…state

Three issues fixed:

1. defaults.csv has quoted headers (from R's write.csv) but parseCSV()
   didn't strip quotes, so getDefaultForAge() always returned null and
   no fields were ever filled. Now parseCSV strips surrounding quotes
   from both headers and values.

2. Replaced all alert() calls with inline banner messages styled within
   the defaults section — warnings in amber, success in green.

3. Button is now disabled when all biomarker fields are filled. State
   updates on input changes and after filling defaults.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
Three changes to form validation behaviour:

1. Show a prominent blue info banner ("Please enter your date of birth
   above to get started") between the date section and biomarker table
   when DOB is empty. Hides automatically once DOB is entered.

2. Implausible range values no longer block the calculation. The
   per-input alerts still appear, but the result is computed regardless.
   Only true errors (invalid input, CRP <= 0) block calculation.

3. When implausible values are present, a red warning banner appears
   under the result listing the affected biomarkers, asking the user
   to check values and units before relying on the result.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
Biomarker validation (range checks, invalid input, CRP > 0) now runs
regardless of whether DOB is entered. This means users see immediate
feedback on erroneous values as they type.

The DOB prompt escalates from blue info to red error when all biomarker
fields are filled but DOB is still missing, making it clear what's
blocking the calculation.

Flow is now:
1. Validate all biomarker inputs (always)
2. Check if dates are present — if not, show appropriate prompt
3. Validate dates
4. Check all biomarkers filled
5. Calculate result

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
All biomarker values must be positive — zero or negative indicates an
input error. CRP no longer needs a special check since the transform
floor at 0.21 handles undetectable CRP values (entered as 0) gracefully,
but 0 still shouldn't be accepted for any biomarker.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
…sults

Strings:
- All user-facing text extracted to strings/en.json
- Simple t(key, ...args) helper with {0}/{1} positional placeholders
- Strings loaded at startup before config; trivial to add new languages
  by creating e.g. strings/fr.json

Results reorder:
1. Main result line (biological age + acceleration)
2. Share card image with download/share buttons
3. Additional details (chronological age, mortality risk, notes, warnings)
4. Save section (separate from results)

Save section redesign:
- Result link in a readonly <input> with click-to-select and copy button
- Privacy note about the link containing medical data
- Side-by-side buttons for CSV download and browser save
- Warning not to use browser save on shared computers

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
When a biomarker value falls outside the plausible range, check whether
the same numeric value would be plausible if interpreted in a different
available unit. If so, append "Did you mean to select [unit]?" to the
validation message. This catches common mistakes like entering a value
in mg/dL when mmol/L is selected, or vice versa.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
The blanket rejection of zero/negative values now defers to the
plausible range: if plausible_low is set to 0 or below for a biomarker,
zero is accepted and the normal range validation handles it from there.
This avoids a hard error when CRP is reported as "not detectable" (0).

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
Replace the single "N values were filled with population averages" note
with escalating warnings based on how many of the 9 biomarker values
are defaults:

- 1: "This result is approximate: one value is a population average."
- 2-3: "approximate: N of 9 values are population averages."
- 4-5: "very approximate" with count
- 6-8: "extremely approximate" with plea to enter more values
- 9/9: "entirely based on population averages!"

Higher severity (>=1/3 defaults) uses a bold amber warning style
instead of the standard red alert.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
claude added 12 commits March 26, 2026 10:34
- localStorage is no longer written automatically on every calculation;
  only when the user explicitly clicks "Save to this browser"
- Save section split into distinct subsections, each with its own
  descriptive note:
  - CSV: "Download as CSV" + "Load from CSV" side by side, with note
    about the file format and that it's readable by anyone on the computer
  - Browser: "Save to this browser" with warning about shared computers
    (now in red to stand out)
- CSV upload parses the same format downloadCSV produces, restoring
  DOB, test date, biomarker values and units, then recalculates

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
The browser was caching strings/en.json across deploys, causing newly
added keys to show as raw key names. Append a timestamp query param
to the fetch URL to ensure fresh loads.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
Major overhaul of the share card system:

HTML card replaces canvas:
- Uses the new visual design with dark themed backgrounds (green for
  younger, blue for on-track, amber for older), large bio age display,
  badge text, and a number line showing bio vs chrono age
- All text is real DOM content, accessible to screen readers
- role="img" with aria-label on wrapper summarises the result
- Number line is aria-hidden (purely decorative, info is in text)
- Label collision handling: when bio and chrono ages are within ~2
  years, labels stack vertically instead of overlapping

PNG generation via modern-screenshot:
- Vendored modern-screenshot v4.6.8 (~29KB UMD) which uses SVG
  foreignObject approach — handles inline styles, backgrounds, and
  gradients well (important for future visual enhancements)
- PNG rendered at 2x scale (1200x1200) for crisp sharing
- Generated async after card renders; cached as blob
- Right-click "Save image as" works via an <img> overlay that
  replaces the HTML card once the PNG is ready
- Download and native share buttons use the cached blob

Also:
- Removed cache-busting timestamp from strings fetch
- Updated en.json with new card strings (split labels, methodology
  text, aria label template)
- Responsive CSS scales the 600px card on narrow viewports

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
- Reduce CTA/URL font sizes (42px→28px for URL, 22px→18px for "Calculate
  yours at") so longer URLs like the-li.org/bioage don't crush the layout
- Use methodology variable instead of calling t() twice
- Remove cursor:pointer from the share card image
- Descriptive filenames: my-biological-age-phenoage-{bio}-{chrono}.png
  used consistently for download button, native share, and right-click save
- Wrap <img> in an <a download="..."> so right-click "Save image as"
  suggests the correct filename instead of Untitled.png
- Use object URL (from blob) for the image src instead of data URL,
  which also improves memory efficiency

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
Self-host Inter (weights 300/400/500/700, latin subset, ~96KB total)
in fonts/ directory so the calculator is fully self-contained when
embedded on third-party sites. This avoids depending on the WordPress
theme's font files or Google Fonts CDN (and the associated GDPR
concerns).

- fonts/inter.css: @font-face declarations with font-display: swap
- fonts/inter-{300,400,500,700}.woff2: font files
- phenoage.html: loads fonts/inter.css before bioage.css
- bioage.css: sets Inter as the base body font
- Share card inline styles updated to use Inter (required for
  modern-screenshot PNG rendering)

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
Calculator (phenoage.html):
- SEO: canonical link to parent page, noindex so iframes don't
  compete with the host page in search results
- Referrer policy: strict-origin-when-cross-origin for analytics
- color-scheme meta for future light/dark mode support
- ResizeObserver notifies parent iframe of height changes, solving
  the timing problem where JS-built form, results, and share card
  would overflow the initial iframe height. Falls back to resize
  event + DOMContentLoaded for older browsers
- URL params config stub (window.embedConfig) reads query string
  for future theme/accent options
- "Embed this calculator" link at bottom → /embed/#calculators/phenoage

Embed hub (embed/index.html):
- Calculator selector dropdown, pre-selectable via URL hash
  (e.g. /embed/#calculators/phenoage)
- Live preview iframe with auto-resize
- Generated embed snippet with:
  - sandbox: allow-scripts, allow-same-origin, allow-popups,
    allow-downloads (needed for CSV/PNG)
  - allow: web-share, clipboard-write
  - auto-resize listener keyed to li-calculator-resize messages
  - origin-checked for production, permissive for preview
- Copy button with "Copied!" feedback
- Max width option (more config coming later)
- Local dev detection: preview uses relative paths

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
Reorganise files into the deployment structure for embeddable
calculators hosted at /embed/ on the production site:

  embed/
    index.html                      <- embed hub with snippet generator
    shared/
      embed.css                     <- shared calculator styles (was bioage.css)
      fonts/inter.css, *.woff2      <- self-hosted Inter font
      vendor/modern-screenshot.js   <- PNG generation library
    calculators/
      phenoage/
        index.html                  <- the calculator (was phenoage.html)
        phenoage.js
        config/                     <- model config, conversions, defaults
        strings/en.json             <- i18n strings

All relative paths updated:
- Calculator HTML references ../../shared/ for fonts, CSS, vendor JS
- Embed hub references shared/ for fonts
- phenoage.js fetch paths (config/, strings/) unchanged — they're
  relative to the calculator's own directory

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
The canonical tag already directs search engines to attribute
ranking signals to the parent page. noindex was redundant and
prevented the embed URL's blood test keywords from contributing
to SEO value.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
New calculator at embed/calculators/dogyears/ using the
16*ln(dog_age)+31 formula from Wang et al. 2020. Features
years+months input, comparison to traditional 7x formula,
i18n strings, and page-specific CSS. Added to embed hub dropdown.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
Photo upload with circular crop, deep teal card theme, paw print
placeholder when no photo is provided. Uses modern-screenshot for
PNG generation. Supports download and native Web Share API.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
Theme system:
- CSS custom properties for all colors in embed.css
- Three themes: light (LI brand default), dark, auto (prefers-color-scheme)
- ?theme=dark or ?theme=auto URL param on calculator pages
- Embed hub now has theme selector in snippet generator
- color-scheme meta tag updated dynamically per theme
- Native form controls (date pickers, selects) follow theme

Attribution link:
- Changed from "Get the embed code" to "Find out more at The Longevity Initiative"
- Hidden on own site via ?source=li param

Bug fixes from code review:
- Fix cross-talk: embed snippet checks e.source === iframe.contentWindow
  so multiple calculators on one page don't resize each other
- CSV parser: handle commas inside quoted fields
- Rename misleading canonicalValues param to refValues in applyTransform

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
The JS already overrides this for ?theme=dark and ?theme=auto.
Having "light dark" in the static HTML caused native form controls
to briefly render in dark mode before the script ran.

https://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants