Refactor PhenoAge calculator to use config-driven model#7
Open
Refactor PhenoAge calculator to use config-driven model#7
Conversation
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
|
LGTM, I see CRP didn't trip the AI off (or at least not in an obvious way) |
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
…b.com/ajsteele/bioage into claude/refactor-phenoage-config-KEFPW
…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
…b.com/ajsteele/bioage into claude/refactor-phenoage-config-KEFPW
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
- 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 biomarkerconfig/models/phenoage.json: Complete model definition including coefficients, transforms, and calculation constantsUnit Conversion Engine: Implemented
toCanonical()andfromCanonical()functions to handle bidirectional unit conversions, replacing the hardcoded conversion arraysTransform 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 modelsData Structure Refactoring:
testsarray with dynamically builtformTestsarrayCode Quality: Improved code organization with clear sections (config loading, conversions, transforms, model calculation, form generation), better variable naming, and consistent formatting
Notable Implementation Details
transformfields on biomarkers for complex calculationshttps://claude.ai/code/session_01NsLwDztaBsRdjJXsg2HvPh