From eec45a8ba469bc1551858628a4b502eab6ff5385 Mon Sep 17 00:00:00 2001 From: holoros Date: Thu, 11 Jun 2026 21:42:38 -0400 Subject: [PATCH 1/2] feat(permanence): CONUS reversal-risk choropleth Adds a precompute script (build_permanence_risk.mjs) that summarizes per-state reversal risk from the committed series into a small permanence_risk.json (49 states), and a self-contained CONUS choropleth (PermanenceMap.jsx) that shades each state by how far its disturbance- exposed reserve falls below the passive reserve at horizon. Click a state to load it. Wired into the Permanence tab above the per-state panel. Build verified; values cross-checked (ME 66%, IN 84%, GA 48%). --- public/api/permanence_risk.json | 499 ++++++++++++++++++++++++++++++ scripts/build_permanence_risk.mjs | 63 ++++ src/App.jsx | 4 +- src/PermanenceMap.jsx | 65 ++++ src/PermanenceRisk.jsx | 9 +- 5 files changed, 638 insertions(+), 2 deletions(-) create mode 100644 public/api/permanence_risk.json create mode 100644 scripts/build_permanence_risk.mjs create mode 100644 src/PermanenceMap.jsx diff --git a/public/api/permanence_risk.json b/public/api/permanence_risk.json new file mode 100644 index 0000000..d76f9a5 --- /dev/null +++ b/public/api/permanence_risk.json @@ -0,0 +1,499 @@ +{ + "meta": { + "generated": "2026-06-12T01:34:09.036Z", + "note": "Per-state reversal risk: cross-engine median reserve carbon, passive vs disturbance-exposed vs mortality-stressed. distPct = % shortfall of disturbance-exposed reserve vs passive reserve at horizon; distSource = peak-to-end drawdown of the disturbance-exposed reserve.", + "n_states": 49 + }, + "states": { + "AL": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 1205.5, + "dist": 540.6, + "mort": 708.1, + "distPct": 55.2, + "mortPct": 41.3, + "distSource": 11.3 + }, + "AR": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 1086.5, + "dist": 159.1, + "mort": 309.5, + "distPct": 85.4, + "mortPct": 71.5, + "distSource": 45.8 + }, + "AZ": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 165.3, + "dist": 140.6, + "mort": 188.9, + "distPct": 14.9, + "mortPct": -14.3, + "distSource": 12.2 + }, + "CA": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 726.4, + "dist": 116.4, + "mort": 365.2, + "distPct": 84, + "mortPct": 49.7, + "distSource": 66.9 + }, + "CO": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 158.8, + "dist": 26.3, + "mort": 101.1, + "distPct": 83.5, + "mortPct": 36.3, + "distSource": 79.7 + }, + "CT": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 65.5, + "dist": 18.2, + "mort": 27, + "distPct": 72.3, + "mortPct": 58.8, + "distSource": 23.4 + }, + "DE": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 27.6, + "dist": 8.9, + "mort": 12.5, + "distPct": 67.9, + "mortPct": 54.7, + "distSource": 12.5 + }, + "FL": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 950.6, + "dist": 238.4, + "mort": 452, + "distPct": 74.9, + "mortPct": 52.5, + "distSource": 28 + }, + "GA": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 1157.5, + "dist": 606, + "mort": 868.9, + "distPct": 47.6, + "mortPct": 24.9, + "distSource": 17.6 + }, + "IA": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 125, + "dist": 8.6, + "mort": 39.9, + "distPct": 93.1, + "mortPct": 68.1, + "distSource": 80.3 + }, + "ID": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 1714.7, + "dist": 285.1, + "mort": 463.2, + "distPct": 83.4, + "mortPct": 73, + "distSource": 24.5 + }, + "IL": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 280.9, + "dist": 79.9, + "mort": 148, + "distPct": 71.5, + "mortPct": 47.3, + "distSource": 30.8 + }, + "IN": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 359.6, + "dist": 56.2, + "mort": 137, + "distPct": 84.4, + "mortPct": 61.9, + "distSource": 51.4 + }, + "KS": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 191.5, + "dist": 33.3, + "mort": 81.3, + "distPct": 82.6, + "mortPct": 57.5, + "distSource": 39.9 + }, + "KY": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 496.9, + "dist": 136.6, + "mort": 223.1, + "distPct": 72.5, + "mortPct": 55.1, + "distSource": 28 + }, + "LA": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 553.2, + "dist": 366.3, + "mort": 445.9, + "distPct": 33.8, + "mortPct": 19.4, + "distSource": 5.3 + }, + "MA": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 104.9, + "dist": 48.5, + "mort": 65.6, + "distPct": 53.7, + "mortPct": 37.4, + "distSource": 11.4 + }, + "MD": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 100.9, + "dist": 40.7, + "mort": 60.8, + "distPct": 59.6, + "mortPct": 39.8, + "distSource": 24.2 + }, + "ME": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 567.8, + "dist": 192.4, + "mort": 280.4, + "distPct": 66.1, + "mortPct": 50.6, + "distSource": 18.3 + }, + "MI": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 1794.1, + "dist": 1025.1, + "mort": 1325.3, + "distPct": 42.9, + "mortPct": 26.1, + "distSource": 7.1 + }, + "MN": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 445.6, + "dist": 255.1, + "mort": 319.3, + "distPct": 42.8, + "mortPct": 28.3, + "distSource": 8.5 + }, + "MO": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 891.6, + "dist": 517.5, + "mort": 671.4, + "distPct": 42, + "mortPct": 24.7, + "distSource": 12.4 + }, + "MS": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 603, + "dist": 229.3, + "mort": 397.7, + "distPct": 62, + "mortPct": 34.1, + "distSource": 30.1 + }, + "MT": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 320.8, + "dist": 67.3, + "mort": 269.6, + "distPct": 79, + "mortPct": 16, + "distSource": 75.9 + }, + "NC": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 1286.6, + "dist": 225.5, + "mort": 606.7, + "distPct": 82.5, + "mortPct": 52.8, + "distSource": 52.5 + }, + "ND": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 28.8, + "dist": 4.9, + "mort": 8, + "distPct": 83, + "mortPct": 72.1, + "distSource": 22.2 + }, + "NE": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 56.3, + "dist": 8.7, + "mort": 14.7, + "distPct": 84.5, + "mortPct": 73.9, + "distSource": 27 + }, + "NH": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 241.2, + "dist": 70.6, + "mort": 89.6, + "distPct": 70.7, + "mortPct": 62.8, + "distSource": 19 + }, + "NJ": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 112.4, + "dist": 20.4, + "mort": 45.7, + "distPct": 81.9, + "mortPct": 59.4, + "distSource": 55.8 + }, + "NM": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 122.4, + "dist": 84.9, + "mort": 132.4, + "distPct": 30.7, + "mortPct": -8.1, + "distSource": 21.3 + }, + "NV": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 15, + "dist": 41.9, + "mort": 55.9, + "distPct": -179.2, + "mortPct": -272.4, + "distSource": 9.6 + }, + "NY": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 624.4, + "dist": 172.3, + "mort": 425.9, + "distPct": 72.4, + "mortPct": 31.8, + "distSource": 43 + }, + "OH": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 301.4, + "dist": 63.7, + "mort": 154.2, + "distPct": 78.9, + "mortPct": 48.8, + "distSource": 38.4 + }, + "OK": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 382.5, + "dist": 80.9, + "mort": 118.9, + "distPct": 78.9, + "mortPct": 68.9, + "distSource": 16.3 + }, + "OR": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 4326.4, + "dist": 529.5, + "mort": 1216.5, + "distPct": 87.8, + "mortPct": 71.9, + "distSource": 41.8 + }, + "PA": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 657.6, + "dist": 251.9, + "mort": 395.3, + "distPct": 61.7, + "mortPct": 39.9, + "distSource": 21.4 + }, + "RI": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 24.4, + "dist": 7.5, + "mort": 9.9, + "distPct": 69.3, + "mortPct": 59.4, + "distSource": 30.4 + }, + "SC": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 1091, + "dist": 385.6, + "mort": 539.4, + "distPct": 64.7, + "mortPct": 50.6, + "distSource": 14.6 + }, + "SD": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 56.5, + "dist": 7.9, + "mort": 26.9, + "distPct": 86, + "mortPct": 52.4, + "distSource": 66.3 + }, + "TN": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 829.4, + "dist": 340.6, + "mort": 460.8, + "distPct": 58.9, + "mortPct": 44.4, + "distSource": 8.6 + }, + "TX": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 1151.6, + "dist": 331.9, + "mort": 413.5, + "distPct": 71.2, + "mortPct": 64.1, + "distSource": 11.3 + }, + "US": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 17997.3, + "dist": 9408.5, + "mort": 15690.9, + "distPct": 47.7, + "mortPct": 12.8, + "distSource": 21.8 + }, + "UT": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 90.2, + "dist": 53.7, + "mort": 100.4, + "distPct": 40.4, + "mortPct": -11.3, + "distSource": 46.9 + }, + "VA": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 1202.1, + "dist": 326.7, + "mort": 570.2, + "distPct": 72.8, + "mortPct": 52.6, + "distSource": 31.4 + }, + "VT": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 213.2, + "dist": 100.5, + "mort": 131.5, + "distPct": 52.9, + "mortPct": 38.3, + "distSource": 8.4 + }, + "WA": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 3289.2, + "dist": 378.9, + "mort": 962.3, + "distPct": 88.5, + "mortPct": 70.7, + "distSource": 50.3 + }, + "WI": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 1535.3, + "dist": 448.2, + "mort": 799.9, + "distPct": 70.8, + "mortPct": 47.9, + "distSource": 28.5 + }, + "WV": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 480.6, + "dist": 258.7, + "mort": 352.8, + "distPct": 46.2, + "mortPct": 26.6, + "distSource": 10.9 + }, + "WY": { + "metric": "agc_live_total", + "endYr": 2125, + "base": 113.2, + "dist": 17, + "mort": 97.9, + "distPct": 85, + "mortPct": 13.5, + "distSource": 81.7 + } + } +} \ No newline at end of file diff --git a/scripts/build_permanence_risk.mjs b/scripts/build_permanence_risk.mjs new file mode 100644 index 0000000..f8821f6 --- /dev/null +++ b/scripts/build_permanence_risk.mjs @@ -0,0 +1,63 @@ +// Precompute per-state permanence / reversal-risk summary from the committed +// per-state series, so the Permanence choropleth reads one small file instead +// of loading all 48 state series in the browser. +// Output: public/api/permanence_risk.json +import { readFileSync, writeFileSync, readdirSync } from "fs"; +import { join } from "path"; + +const DIR = "public/api/series"; +const B = { base:"reserve (no harvest)", + dist:"reserve (no harvest, disturbance-exposed)", + mort:"reserve (no harvest, mortality-stressed)" }; +const PREF = ["agc_live_total","agb_dry","agc_live_ag","bgc_live_total","vol_stem"]; + +const medianByYear = arr => { + if(!arr || !arr.length) return []; + const byYr = {}; + arr.forEach(s => (s.pts||[]).forEach(p => { + if(p[1]==null || Number.isNaN(p[1])) return; + (byYr[p[0]] = byYr[p[0]] || []).push(p[1]); + })); + return Object.keys(byYr).map(Number).sort((a,b)=>a-b).map(y=>{ + const v = byYr[y].slice().sort((a,b)=>a-b); + const m = v.length%2 ? v[(v.length-1)/2] : (v[v.length/2-1]+v[v.length/2])/2; + return [y,m]; + }); +}; +const valAt = (line,yr) => { + if(!line.length) return null; + if(yr<=line[0][0]) return line[0][1]; + if(yr>=line[line.length-1][0]) return line[line.length-1][1]; + for(let i=1;i=a[0]&&yr<=b[0]){ const t=(yr-a[0])/((b[0]-a[0])||1); return a[1]+t*(b[1]-a[1]); } } + return null; +}; + +const out = {}; +let n=0; +for(const f of readdirSync(DIR).filter(f=>f.endsWith(".json"))){ + const st = f.replace(".json",""); + let d; try { d = JSON.parse(readFileSync(join(DIR,f),"utf8")); } catch { continue; } + let metric=null; + for(const m of PREF){ const nd=d[m]; if(nd && nd[B.base] && (nd[B.dist]||nd[B.mort])){ metric=m; break; } } + if(!metric) continue; + const nd=d[metric]; + const base=medianByYear(nd[B.base]); if(!base.length) continue; + const dist=medianByYear(nd[B.dist]); const mort=medianByYear(nd[B.mort]); + const endYr=base[base.length-1][0], bEnd=base[base.length-1][1]; + const dEnd=dist.length?valAt(dist,endYr):null, mEnd=mort.length?valAt(mort,endYr):null; + const distPct=(dEnd!=null&&bEnd)?(bEnd-dEnd)/bEnd*100:null; + const mortPct=(mEnd!=null&&bEnd)?(bEnd-mEnd)/bEnd*100:null; + const distPk=dist.length?Math.max(...dist.map(p=>p[1])):null; + const distSource=(distPk!=null&&dEnd!=null&&distPk)?(distPk-dEnd)/distPk*100:null; + out[st]={ metric, endYr, + base:+bEnd.toFixed(1), dist:dEnd!=null?+dEnd.toFixed(1):null, mort:mEnd!=null?+mEnd.toFixed(1):null, + distPct:distPct!=null?+distPct.toFixed(1):null, mortPct:mortPct!=null?+mortPct.toFixed(1):null, + distSource:distSource!=null?+distSource.toFixed(1):null }; + n++; +} +writeFileSync("public/api/permanence_risk.json", + JSON.stringify({ meta:{ generated:new Date().toISOString(), + note:"Per-state reversal risk: cross-engine median reserve carbon, passive vs disturbance-exposed vs mortality-stressed. distPct = % shortfall of disturbance-exposed reserve vs passive reserve at horizon; distSource = peak-to-end drawdown of the disturbance-exposed reserve.", + n_states:n }, states:out }, null, 1)); +console.log("wrote public/api/permanence_risk.json for", n, "states"); diff --git a/src/App.jsx b/src/App.jsx index 62bdea4..ec2d50e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -472,6 +472,7 @@ export default function App(){ const [landis,setLandis] = useState(null); const [landowner,setLandowner] = useState(null); const [faustmann,setFaustmann] = useState(null); + const [permRisk,setPermRisk] = useState(null); // v1.3 map/AOI tools const [ecoOn,setEcoOn] = useState(false); const [ecoGeo,setEcoGeo] = useState(null); @@ -500,6 +501,7 @@ export default function App(){ j("api/landis_stratified.json").then(setLandis).catch(()=>{}); j("api/landowner_yields.json").then(setLandowner).catch(()=>{}); j("api/faustmann_rotation.json").then(setFaustmann).catch(()=>{}); + j("api/permanence_risk.json").then(d=>setPermRisk(d && d.states)).catch(()=>{}); geo.features.forEach(ft=>{ const st=ft.properties.state; const c=s[st]; ft.properties.engines = c ? c.engines : 0; ft.properties.hasSeries = (c && c.has_series) ? 1 : 0; @@ -1428,7 +1430,7 @@ export default function App(){ {(!aoi || researchOpen) && tab==="landis" && } {(!aoi || researchOpen) && tab==="landowner" && } {(!aoi || researchOpen) && tab==="faustmann" && } - {(!aoi || researchOpen) && tab==="permanence" && } + {(!aoi || researchOpen) && tab==="permanence" && setSel(st)}/>} {(!aoi || researchOpen) && (tab==="engines"||tab==="rd") && (<> {LANDIS_STATES.includes(sel) && (
diff --git a/src/PermanenceMap.jsx b/src/PermanenceMap.jsx new file mode 100644 index 0000000..6b80515 --- /dev/null +++ b/src/PermanenceMap.jsx @@ -0,0 +1,65 @@ +// Compact CONUS choropleth of reversal risk for the Permanence view. +// Self-contained Albers projection (mirrors SVGMap.jsx) so this view does not +// need to thread the full map component or load all-state series — it reads the +// precomputed public/api/permanence_risk.json summary. +const W=640, H=400, PAD=8; +const PHI0=37.5*Math.PI/180, PHI1=29.5*Math.PI/180, PHI2=45.5*Math.PI/180, LAM0=-96*Math.PI/180; +const N=(Math.sin(PHI1)+Math.sin(PHI2))/2; +const C=Math.cos(PHI1)**2+2*N*Math.sin(PHI1); +const RHO0=Math.sqrt(C-2*N*Math.sin(PHI0))/N; +function project(lon,lat){ const phi=lat*Math.PI/180, lam=lon*Math.PI/180; + const rho=Math.sqrt(Math.max(0,C-2*N*Math.sin(phi)))/N, theta=N*(lam-LAM0); + return [rho*Math.sin(theta), RHO0-rho*Math.cos(theta)]; } +const _c=[project(-125,50),project(-66,50),project(-125,24),project(-66,24),project(-95,49),project(-95,25)]; +const _xs=_c.map(c=>c[0]), _ys=_c.map(c=>c[1]); +const _x0=Math.min(..._xs),_x1=Math.max(..._xs),_y0=Math.min(..._ys),_y1=Math.max(..._ys); +const _dx=_x1-_x0,_dy=_y1-_y0, SCALE=Math.min((W-2*PAD)/_dx,(H-2*PAD)/_dy); +const TX=-_x0*SCALE+(W-_dx*SCALE)/2, TY=PAD+SCALE*_y1+(H-2*PAD-_dy*SCALE)/2; +const projPath=(lon,lat)=>{ const [x,y]=project(lon,lat); return [x*SCALE+TX,-y*SCALE+TY]; }; +const ringToD=r=>{ let d=""; for(let i=0;i{ if(!g) return ""; const polys=g.type==="Polygon"?[g.coordinates]:g.coordinates; return polys.map(p=>p.map(ringToD).join(" ")).join(" "); }; + +// sequential risk ramp: low (green) -> moderate (amber) -> high (red) +const STOPS=[[0,[47,158,106]],[40,[202,161,90]],[70,[224,90,90]]]; +const rampRisk=v=>{ if(v==null||isNaN(v)) return "#2a3a47"; + v=Math.max(0,Math.min(70,v)); + let a=STOPS[0],b=STOPS[STOPS.length-1]; + for(let i=1;iMath.round(ca+t*(b[1][k]-ca))); + return "#"+c.map(x=>x.toString(16).padStart(2,"0")).join(""); }; + +export default function PermanenceMap({ geo, risk, field="distPct", selected, onPick }){ + if(!geo || !geo.features || !risk) return null; + const feats=geo.features.filter(ft=>ft.properties && ft.properties.state); + const legendVals=[0,20,40,55,70]; + return ( +
+ + + {feats.map(ft=>{ + const st=ft.properties.state, r=risk[st]; + const v=r?r[field]:null; + const fill=rampRisk(v); + const isSel=st===selected; + return { if(r && onPick) onPick(st); }}> + {`${st}${r?` · disturbance-exposed reserve ${v!=null?v.toFixed(0)+"% below passive":"—"} at ${r.endYr}`:" · no permanence data"}`} + ; + })} + {/* legend */} + + {legendVals.map((v,i)=>())} + lower + higher reversal risk + + +
+ Each state shaded by how far its disturbance-exposed no-harvest reserve falls below the passive reserve at horizon (cross-engine median). Click a state to load it. {Object.keys(risk).length} states. +
+
+ ); +} diff --git a/src/PermanenceRisk.jsx b/src/PermanenceRisk.jsx index 39c18b9..0c16322 100644 --- a/src/PermanenceRisk.jsx +++ b/src/PermanenceRisk.jsx @@ -7,6 +7,7 @@ // disturbance / mortality, and where does a no-harvest reserve plateau or // turn into a net source? import { useMemo, useState } from "react"; +import PermanenceMap from "./PermanenceMap.jsx"; const BUCKETS = { base: "reserve (no harvest)", @@ -42,7 +43,7 @@ const valAt = (line, yr) => { }; const peak = line => line.reduce((m,p)=>p[1]>m?p[1]:m, -Infinity); -export default function PermanenceRisk({ series, state, meta, stateName }){ +export default function PermanenceRisk({ series, state, meta, stateName, geo, risk, onPick }){ // choose a carbon-stock metric that actually has the reserve buckets const metric = useMemo(()=>{ if(!series) return null; @@ -129,6 +130,12 @@ export default function PermanenceRisk({ series, state, meta, stateName }){ return (
+ {geo && risk && ( +
+
CONUS reversal risk
+ +
+ )}
Permanence & reversal risk — {stateName||state} {label} · ensemble median of {data.nEng} reserve engine{data.nEng===1?"":"s"} · {unit} From 0a4d38a5e812f4be5754f7ffb12550675dbe0732 Mon Sep 17 00:00:00 2001 From: holoros Date: Fri, 12 Jun 2026 04:45:28 -0400 Subject: [PATCH 2/2] fix(permanence): flag sparse-base states (NV/RI) as not-characterized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stress test found NV's reversal at -179% (disturbance-exposed median above passive) because its forested carbon base is tiny (15 Tg sparse woodland) — the known NV edge case. Precompute now marks states with a small base or negative shortfall reliable:false; the choropleth grays them out instead of mis-coloring them low-risk, and the per-state view shows a 'not characterized' note. NV and RI flagged. Build verified. --- public/api/permanence_risk.json | 51 ++++++++++++++++++++++++++++++- scripts/build_permanence_risk.mjs | 3 +- src/PermanenceMap.jsx | 6 ++-- src/PermanenceRisk.jsx | 2 ++ 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/public/api/permanence_risk.json b/public/api/permanence_risk.json index d76f9a5..62443c7 100644 --- a/public/api/permanence_risk.json +++ b/public/api/permanence_risk.json @@ -1,6 +1,6 @@ { "meta": { - "generated": "2026-06-12T01:34:09.036Z", + "generated": "2026-06-12T08:44:58.157Z", "note": "Per-state reversal risk: cross-engine median reserve carbon, passive vs disturbance-exposed vs mortality-stressed. distPct = % shortfall of disturbance-exposed reserve vs passive reserve at horizon; distSource = peak-to-end drawdown of the disturbance-exposed reserve.", "n_states": 49 }, @@ -8,6 +8,7 @@ "AL": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 1205.5, "dist": 540.6, "mort": 708.1, @@ -18,6 +19,7 @@ "AR": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 1086.5, "dist": 159.1, "mort": 309.5, @@ -28,6 +30,7 @@ "AZ": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 165.3, "dist": 140.6, "mort": 188.9, @@ -38,6 +41,7 @@ "CA": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 726.4, "dist": 116.4, "mort": 365.2, @@ -48,6 +52,7 @@ "CO": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 158.8, "dist": 26.3, "mort": 101.1, @@ -58,6 +63,7 @@ "CT": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 65.5, "dist": 18.2, "mort": 27, @@ -68,6 +74,7 @@ "DE": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 27.6, "dist": 8.9, "mort": 12.5, @@ -78,6 +85,7 @@ "FL": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 950.6, "dist": 238.4, "mort": 452, @@ -88,6 +96,7 @@ "GA": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 1157.5, "dist": 606, "mort": 868.9, @@ -98,6 +107,7 @@ "IA": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 125, "dist": 8.6, "mort": 39.9, @@ -108,6 +118,7 @@ "ID": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 1714.7, "dist": 285.1, "mort": 463.2, @@ -118,6 +129,7 @@ "IL": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 280.9, "dist": 79.9, "mort": 148, @@ -128,6 +140,7 @@ "IN": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 359.6, "dist": 56.2, "mort": 137, @@ -138,6 +151,7 @@ "KS": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 191.5, "dist": 33.3, "mort": 81.3, @@ -148,6 +162,7 @@ "KY": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 496.9, "dist": 136.6, "mort": 223.1, @@ -158,6 +173,7 @@ "LA": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 553.2, "dist": 366.3, "mort": 445.9, @@ -168,6 +184,7 @@ "MA": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 104.9, "dist": 48.5, "mort": 65.6, @@ -178,6 +195,7 @@ "MD": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 100.9, "dist": 40.7, "mort": 60.8, @@ -188,6 +206,7 @@ "ME": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 567.8, "dist": 192.4, "mort": 280.4, @@ -198,6 +217,7 @@ "MI": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 1794.1, "dist": 1025.1, "mort": 1325.3, @@ -208,6 +228,7 @@ "MN": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 445.6, "dist": 255.1, "mort": 319.3, @@ -218,6 +239,7 @@ "MO": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 891.6, "dist": 517.5, "mort": 671.4, @@ -228,6 +250,7 @@ "MS": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 603, "dist": 229.3, "mort": 397.7, @@ -238,6 +261,7 @@ "MT": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 320.8, "dist": 67.3, "mort": 269.6, @@ -248,6 +272,7 @@ "NC": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 1286.6, "dist": 225.5, "mort": 606.7, @@ -258,6 +283,7 @@ "ND": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 28.8, "dist": 4.9, "mort": 8, @@ -268,6 +294,7 @@ "NE": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 56.3, "dist": 8.7, "mort": 14.7, @@ -278,6 +305,7 @@ "NH": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 241.2, "dist": 70.6, "mort": 89.6, @@ -288,6 +316,7 @@ "NJ": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 112.4, "dist": 20.4, "mort": 45.7, @@ -298,6 +327,7 @@ "NM": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 122.4, "dist": 84.9, "mort": 132.4, @@ -308,6 +338,7 @@ "NV": { "metric": "agc_live_total", "endYr": 2125, + "reliable": false, "base": 15, "dist": 41.9, "mort": 55.9, @@ -318,6 +349,7 @@ "NY": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 624.4, "dist": 172.3, "mort": 425.9, @@ -328,6 +360,7 @@ "OH": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 301.4, "dist": 63.7, "mort": 154.2, @@ -338,6 +371,7 @@ "OK": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 382.5, "dist": 80.9, "mort": 118.9, @@ -348,6 +382,7 @@ "OR": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 4326.4, "dist": 529.5, "mort": 1216.5, @@ -358,6 +393,7 @@ "PA": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 657.6, "dist": 251.9, "mort": 395.3, @@ -368,6 +404,7 @@ "RI": { "metric": "agc_live_total", "endYr": 2125, + "reliable": false, "base": 24.4, "dist": 7.5, "mort": 9.9, @@ -378,6 +415,7 @@ "SC": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 1091, "dist": 385.6, "mort": 539.4, @@ -388,6 +426,7 @@ "SD": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 56.5, "dist": 7.9, "mort": 26.9, @@ -398,6 +437,7 @@ "TN": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 829.4, "dist": 340.6, "mort": 460.8, @@ -408,6 +448,7 @@ "TX": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 1151.6, "dist": 331.9, "mort": 413.5, @@ -418,6 +459,7 @@ "US": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 17997.3, "dist": 9408.5, "mort": 15690.9, @@ -428,6 +470,7 @@ "UT": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 90.2, "dist": 53.7, "mort": 100.4, @@ -438,6 +481,7 @@ "VA": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 1202.1, "dist": 326.7, "mort": 570.2, @@ -448,6 +492,7 @@ "VT": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 213.2, "dist": 100.5, "mort": 131.5, @@ -458,6 +503,7 @@ "WA": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 3289.2, "dist": 378.9, "mort": 962.3, @@ -468,6 +514,7 @@ "WI": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 1535.3, "dist": 448.2, "mort": 799.9, @@ -478,6 +525,7 @@ "WV": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 480.6, "dist": 258.7, "mort": 352.8, @@ -488,6 +536,7 @@ "WY": { "metric": "agc_live_total", "endYr": 2125, + "reliable": true, "base": 113.2, "dist": 17, "mort": 97.9, diff --git a/scripts/build_permanence_risk.mjs b/scripts/build_permanence_risk.mjs index f8821f6..c20d88a 100644 --- a/scripts/build_permanence_risk.mjs +++ b/scripts/build_permanence_risk.mjs @@ -50,7 +50,8 @@ for(const f of readdirSync(DIR).filter(f=>f.endsWith(".json"))){ const mortPct=(mEnd!=null&&bEnd)?(bEnd-mEnd)/bEnd*100:null; const distPk=dist.length?Math.max(...dist.map(p=>p[1])):null; const distSource=(distPk!=null&&dEnd!=null&&distPk)?(distPk-dEnd)/distPk*100:null; - out[st]={ metric, endYr, + const reliable = bEnd>=25 && (distPct==null || distPct>=-5); + out[st]={ metric, endYr, reliable, base:+bEnd.toFixed(1), dist:dEnd!=null?+dEnd.toFixed(1):null, mort:mEnd!=null?+mEnd.toFixed(1):null, distPct:distPct!=null?+distPct.toFixed(1):null, mortPct:mortPct!=null?+mortPct.toFixed(1):null, distSource:distSource!=null?+distSource.toFixed(1):null }; diff --git a/src/PermanenceMap.jsx b/src/PermanenceMap.jsx index 6b80515..cb25abf 100644 --- a/src/PermanenceMap.jsx +++ b/src/PermanenceMap.jsx @@ -39,15 +39,15 @@ export default function PermanenceMap({ geo, risk, field="distPct", selected, on {feats.map(ft=>{ const st=ft.properties.state, r=risk[st]; - const v=r?r[field]:null; - const fill=rampRisk(v); + const v=(r && r.reliable!==false)?r[field]:null; + const fill=(r && r.reliable===false)?"#3a4654":rampRisk(v); const isSel=st===selected; return { if(r && onPick) onPick(st); }}> - {`${st}${r?` · disturbance-exposed reserve ${v!=null?v.toFixed(0)+"% below passive":"—"} at ${r.endYr}`:" · no permanence data"}`} + {`${st}${r?(r.reliable===false?` · sparse forest base — reversal not characterized`:` · disturbance-exposed reserve ${v!=null?v.toFixed(0)+"% below passive":"—"} at ${r.endYr}`):" · no permanence data"}`} ; })} {/* legend */} diff --git a/src/PermanenceRisk.jsx b/src/PermanenceRisk.jsx index 0c16322..05c9cf6 100644 --- a/src/PermanenceRisk.jsx +++ b/src/PermanenceRisk.jsx @@ -114,7 +114,9 @@ export default function PermanenceRisk({ series, state, meta, stateName, geo, ri gapPoly=up+" "+dn+" Z"; } + const sparse = data.bEnd < 25 || (data.distPct!=null && data.distPct < -5); const verdict = (()=>{ + if(sparse) return { t:"Reversal risk: not characterized", d:`This state's forested carbon base is small (${data.bEnd!=null?data.bEnd.toFixed(0):"—"} ${unit}), so the cross-engine reserve median is noisy and the reversal signal is not reliable here (sparse-woodland edge case).`, c:"#8aa0b0" }; const dp=data.distPct, ds=data.distSource; const hi = (dp!=null && dp>=50) || (ds!=null && ds>=25); const mod = (dp!=null && dp>=20) || (ds!=null && ds>=8);