> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getbased.health/llms.txt
> Use this file to discover all available pages before exploring further.

# Sun spectrum model

> Bird-Riordan spectral reconstruction and Light session modeling.

# Sun spectrum model — contributor reference

The Light & Sun lens reconstructs solar spectral irradiance and convolves it through published action spectra to derive per-channel doses. This page documents the model, the action spectra, and the calibration choices for anyone touching `js/sun-spectrum.ts` or `js/sun-uvdata.ts`.

## Pipeline

```
Atmosphere (UV index, ozone, cloud cover, temperature, altitude)
        │  via js/sun-uvdata.js — multi-source ladder:
        │     auto: explicit selfhost (if configured) → deployment CAMS operation
        │           → browser-direct Open-Meteo
        │     selfhost: configured endpoint → Open-Meteo
        │     open-meteo: browser-direct Open-Meteo only
        ▼
Solar zenith angle (NOAA solar position algorithm, ±1° accuracy)
        │  js/sun-uvdata.js → solarZenithAngle(date, lat, lon)
        ▼
Bird-Riordan-style clear-sky reconstruction
        │  js/sun-spectrum.js → reconstructSpectrum()
        │  280-2500 nm at 5 nm resolution (445 bands)
        │  Extraterrestrial × Rayleigh × Ozone (Bass-Paur) × Aerosol × Cloud
        ▼
Per-channel convolution with action spectra
        │  js/sun-spectrum.js → computeChannelDoses()
        │  8 channels × 445 bands × 5nm bandwidth
        ▼
Dose accumulator (channel-au, internal unit)
        │  Legacy normalization anchors remain for stored analyses/correlations
        │  but the current user UI reports source and rhythm, not target progress
        ▼
UI (channel pills, channel cards, AI context)
```

## Wavelength grid

```javascript theme={null}
WAVELENGTHS = [280, 285, 290, ..., 2495, 2500]  // 445 bands at 5nm
```

The 5 nm interval is the application's internal modeling grid. It samples the narrow UV weighting curves more usefully than a coarse grid, but it does not add spectral information to a scalar UVI input or turn the reconstruction into a measurement.

## Action spectra (per channel)

Each channel has a closed-form action spectrum function returning a 0–1 weighting per nm. Defined in `js/sun-spectrum.ts`:

| Channel | Function | Reference | Peak | Bandwidth |
| - | - | - | - | - |
| `vitamin_d` | `vitaminDAt(nm)` | CIE 174:2006 previtamin-D3 (MacLaughlin 1982) | 297 nm | \~252–330 nm |
| `pomc` | `erythemalAt(nm)` | CIE S 007 / ISO 17166:1999 (McKinlay-Diffey 1987) | 297 nm | \~250–400 nm |
| `no_cv` | `noReleaseAt(nm)` | Liu 2014 / Oplander 2009 | 345 nm | 300–410 nm Gaussian |
| `violet_eye` | `opn5At(nm)` | OPN5/neuropsin — Buhr 2019, Yoshikawa 2019 | 380 nm + 471 nm | 320–540 nm |
| `circadian` | `melanopicAt(nm)` | CIE S 026:2018 (ipRGC / melanopsin) | 490 nm | 380–720 nm Gaussian |
| `nir_solar` | `nirSolarAt(nm)` | Optical tissue window (Jacques 2013) | 900 nm | 600–1400 nm |
| `pbm_red` | `pbmRedAt(nm)` | Karu 2010 / Hamblin 2018 (cytochrome c oxidase band) | 660 nm | 600–700 nm |
| `pbm_nir` | `pbmNirAt(nm)` | Karu 2010 / Hamblin 2018 (cytochrome c oxidase band) | 850 nm | 700–1100 nm |

The CCO action spectrum (`ccoAt`) in the file is an unused helper that sums Karu's four absorption bands; PBM channels use the simpler narrowband Gaussians for cleaner therapy-device dose math.

The citation registry is duplicated in `data/sun-action-spectra.json` for AI context grounding.

## Bird-Riordan reconstruction

Implemented as `reconstructSpectrum()` with these terms per wavelength:

```javascript theme={null}
E(λ) = E0(λ) × T_Rayleigh × T_O3 × T_aerosol × cloudT
```

Where:

* **E0(λ)** — extraterrestrial spectral irradiance, hardcoded fit to ASTM E490 reference at sample points 280, 300, 320, 340, 360, 380, 400, 420, 450, 500, 550, 600, 650, 700, 800, 900, 1000, 1200, 1500, 2000, 2500 nm with linear interpolation between
* **T\_Rayleigh** — `exp(-tauR × airMass)` with `tauR = altScale / (λ´ × (115.6406 - 1.335/λ²))`, `λ = nm/1000`, and `altScale = exp(-altitudeM / 8000)`
* **T\_O3** — `exp(-ozoneAbsorption(nm) × airMass × ozoneDU/1000)` with Bass-Paur cross-section approximation peaking in the Hartley band (\~250 nm)
* **T\_aerosol** — `exp(-tauA × airMass)` with `tauA = beta × (nm/500)^-1.14`; `beta` uses a positive supplied AOD value or the `0.10` fallback
* **cloudT** — `1 - 0.75 × cloudCover` (linear cloud transmission)
* **airMass** — `1 / max(cos(zenithDeg × π/180), 0.001)`

This is a coarse deterministic estimate, not validated radiometry. The implementation assigns broad heuristic uncertainty and the UI must preserve that distinction. The full Bird-Riordan model has aerosol single-scattering, multiple scattering, and direct/diffuse separation that this implementation simplifies. A research-grade use would require measurement-backed validation and a more complete radiative-transfer model.

## Channel dose calculation

```javascript theme={null}
computeChannelDoses({ spectrum, durationMin, bodyExposureFraction, eyeExposure }) → {
  vitamin_d: number,
  pomc: number,
  no_cv: number,
  violet_eye: number,
  circadian: number,
  nir_solar: number,
  pbm_red: number,
  pbm_nir: number,
}
```

Per channel:

```javascript theme={null}
dose = Σ_λ E(λ) × W(λ) × dλ × duration × gain
```

Where `gain` is:

* **Whole-body skin-yield proxies** (`vitamin_d`, `pomc`, `no_cv`) → `skinIrradianceMultiplier × bodyExposureFraction`
* **Local skin-fluence channels** (`nir_solar`, `pbm_red`, `pbm_nir`) → `skinIrradianceMultiplier` when any body exposure is logged; they do not shrink with the size of the treated patch
* **Eye channels** (circadian, violet\_eye) → `eyeMultiplier(eyeExposure)`:
  * `direct + clear` → 1.0
  * `clear-glasses` → 0.85 (blocks UV, passes visible)
  * `glass-window` → 0.4 (passes most visible, blocks NIR + UV)
  * `polarized` → 0.5
  * `photochromic` → 0.3
  * `blue-blocker` → 0.4
  * `amber/red` → 0.2
  * `sunglasses` → 0.05
  * `closed-eyes`, `indoor` → 0

Eye-channel doses go to zero when `eyeExposure` is null (no eye exposure logged). Skin channels are unaffected by eye-mode.

## Genetic vitamin D context

`geneticVitaminDMultiplier(genetics)` reads four catalog calls and returns a compound multiplier plus an auditable contributor list:

| SNP | Catalog locus | Genotype multipliers |
| - | - | - |
| `rs2282679` | `GC` / VDBP marker | `TT: 1.00`, `GT: 0.95`, `GG: 0.85` |
| `rs10741657` | `CYP2R1` | `AA: 1.00`, `AG: 0.95`, `GG: 0.88` |
| `rs12785878` | `NADSYN1/DHCR7` region | `TT: 1.00`, `GT: 0.95`, `GG: 0.92` |
| `rs6013897` | `CYP24A1` region | `TT: 1.00`, `AT: 0.97`, `AA: 0.92` |

`vitaminDIURaw()` multiplies the channel result by this compound value before the per-session and daily caps are applied. Missing or reference calls contribute `1.0`.

These are conservative heuristic weights for associations with circulating 25-OH vitamin D. They are not genotype-specific intervention responses and do not directly measure skin synthesis, transport, hydroxylation, or personal blood response. The UI therefore describes an **effective serum response per modeled UV dose**, not genetically calculated vitamin D production.

`VDR rs2228570` and `CYP27B1 rs10877012` remain in the public Genome catalog as informational/mechanistic context but are intentionally excluded from the multiplier because their cited studies do not establish a change in circulating 25-OH vitamin D or UV response. Claim scopes and publication links live in `data/snp-health.json`.

## Safety counters

Two safety counters are computed alongside the channel doses:

### Erythemal SED

```javascript theme={null}
erythemalSED({ spectrum, durationMin, bodyExposureFraction }) → number
```

Standard Erythemal Dose, defined as 100 J/m² of CIE-erythemal-weighted irradiance. Converted to a Fitzpatrick-fraction via:

```javascript theme={null}
fractionOfMED({ sed, fitzpatrick }) → ratio
```

With per-Fitzpatrick MED values from Diffey 1991 / GrassrootsHealth (in SED units): `{ I: 2, II: 2.5, III: 3, IV: 4.5, V: 6, VI: 10 }`.

Burn-risk is `cumulativeMEDToday()` (sum across all sessions today).

### Ocular actinic UV

```javascript theme={null}
ocularActinicUVdose({ spectrum, eyeExposure, zenithDeg, glassBetween }) → J/m²
```

Integrates wavelengths through the ICNIRP actinic-UV weighting up to 400 nm, applies the logged duration and a low-solar-elevation gate, and supports the `direct` and wavelength-attenuated `glass-window` paths. It represents incident anterior-eye actinic UV, not retinal dose and not the visible or thermal hazard of looking at the sun. The legacy export name `retinalUVdose` aliases this function for stored/session compatibility only. It is a safety counter and is never recommended to maximize.

## Legacy normalization anchors

`CHANNEL_DISPLAY[k].dailyTarget` remains an internal compatibility anchor used by older tiered records and correlation helpers:

| Channel | Daily target (channel-au) |
| - | - |
| vitamin\_d | 300 |
| pomc | 80 |
| no\_cv | 5000 |
| violet\_eye | 8000 |
| circadian | 20000 |
| nir\_solar | 30000 |
| pbm\_red | 8000 |
| pbm\_nir | 10000 |

Legacy helpers map these anchors through the following tier function:

```javascript theme={null}
ratio = dose / dailyTarget
dose ≤ 0      → 'none'      // tier 0
ratio < 0.20  → 'low'       // tier 1
ratio < 0.55  → 'moderate'  // tier 2
ratio < 1.00  → 'good'      // tier 3
ratio ≥ 1.00  → 'strong'    // tier 4
```

`weeklyChannelTier()` uses the same ratios with a sevenfold anchor for older seven-day rollups.

These values are not literature-established personal requirements, treatment doses, or user goals. The current channel UI must not expose percentages, deficiency, sufficiency, completion, or progress against them. It shows whether sunlight and/or a device signal was logged, the source split, and a seven-day rhythm. AI prompts carry the same rule: missing logs are missing data, not missing biology.

## Adding a new channel

1. Add an entry to `CHANNEL_DISPLAY` in `js/sun.ts` with `icon`, `label`, `dailyTarget`, and `what` (user-facing tooltip)
2. Add an action-spectrum function to `js/sun-spectrum.ts`
3. Append to the `CHANNELS` array in the same file with `{ id, key, fn, label }`
4. Add a row to `data/sun-action-spectra.json`'s `channels` block with the citation
5. Add to the dashboard pill order in `js/views.ts` and the page/detail render order in `js/light-channel-view.ts`
6. Update `js/sun-correlations.ts` if the channel should be biomarker-correlated
7. Update `tests/test-sun-spectrum.ts` — assert the channel is in `SUN_CHANNELS` and has non-zero dose at noon

## UV data source

`js/sun-uvdata.ts` resolves the active UV data provider via `providerOrder(cfg)`:

| `cfg.mode` | Order |
| - | - |
| `auto` (default) | selfhost (if URL set) → deployment CAMS operation → Open-Meteo |
| `selfhost` | selfhost → Open-Meteo |
| `open-meteo` | Open-Meteo only |

Legacy `cams`, `noaa`, and `manual` modes auto-migrate to `auto` in `getMeteoConfig()`. The current picker exposes only **Auto**, **Open-Meteo**, and **Self-hosted**.

**CAMS compatibility operation:** official clients send the fixed CAMS payload to `https://integrations.getbased.health/api/proxy`; independent deployments use same-origin `/api/proxy`. The operated relay forces latitude and longitude to a 0.1° grid even if a client sends more precision, injects the fixed upstream bearer, and returns a bounded atmosphere envelope. The browser applies the same rounding before a direct Open-Meteo fallback. Self-hosters can run a compatible source and select **Self-hosted**.

The relay does not expose a server-side `/spectrum` reconstruction. It returns atmosphere data; `reconstructSpectrum()` always runs in the browser. CAMS-McRad surface UV remains out of scope because its queue/pre-registered-location model does not fit synchronous per-coordinate requests.

**Source confidence** is computed at read time from source, snapshot age, cloud cover, sun elevation, UVI band, and staleness. It is an uncertainty signal for the modeled input, not radiometer accuracy. Do not restore the retired manual-source shortcut or assign perfect confidence to a user-entered UVI: one value cannot supply the full atmosphere/spectrum model.

## Validation

`tests/test-sun-spectrum.ts` covers \~120 assertions:

* Spectrum shape (wavelength array, 5nm grid, 280–2500 nm bounds, non-negative irradiance)
* Sun-below-horizon → all-zero spectrum
* Atmospheric attenuation (zenith / ozone / cloud / altitude) — directional checks
* Channel dose calculation (all channels, body fraction scaling, eye-mode gating, sunglasses → near-zero circadian)
* Safety counters (SED scales linearly with duration, Fitzpatrick I reaches its reference before VI, ocular actinic UV is weighted and gated by eye/glass conditions)
* Edge cases (night spectrum, zero-duration session)

Quarterly validation against published clear-sky measurement campaigns (NREL SRRL, NOAA SURFRAD) is the next-tier rigour. Keep that report in the developer docs when it exists; do not treat it as part of the current verified test suite.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.