From 5f1cd2a130995dc32b1f02207adad21902562fcb Mon Sep 17 00:00:00 2001 From: holoros Date: Thu, 11 Jun 2026 21:27:54 -0400 Subject: [PATCH] feat(permanence): reversal-risk view over disturbance-exposed reserve scenarios New 'Permanence / risk' tab. Contrasts the passive no-harvest reserve against the disturbance-exposed and mortality-stressed reserve buckets already shipped in the v1.4 API, as the cross-engine ensemble median. Surfaces per-state reversal-risk: end-of-horizon shortfall vs the passive reserve, peak-to-end drawdown (does the reserve become a net source), a graduated risk verdict, scorecards, and a gap-shaded trajectory chart. Directly addresses the dimension single-model reversal tools center on, but on PERSEUS's multi-model footing. Data already present (ME/GA/IN/MN + focal states); graceful empty state where the buckets are absent. Build verified; reversal math sanity-checked against ME/GA/IN. --- src/App.jsx | 7 +- src/PermanenceRisk.jsx | 187 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 src/PermanenceRisk.jsx diff --git a/src/App.jsx b/src/App.jsx index add1dee..62bdea4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,6 +7,7 @@ import StumpagePanel from "./StumpagePanel.jsx"; import LandisStratified from "./LandisStratified.jsx"; import LandownerYields from "./LandownerYields.jsx"; import FaustmannRotation from "./FaustmannRotation.jsx"; +import PermanenceRisk from "./PermanenceRisk.jsx"; import AOIReport from "./AOIReport.jsx"; import { findFeature, agbAtAge, polygonCentroid, polygonAreaM2, pointInGeometry } from "./geo.js"; import { ownershipComposition, riskSummary, forestFraction, forestTypeDiversity, rampRelative, rampValues, median, percentile } from "./rasterSample.js"; @@ -1403,13 +1404,14 @@ export default function App(){ {(!aoi || researchOpen) &&
{[["engines","Engine compare"],["rd","RD trend"],["divergence","Engine spread"], ["stumpage","Stumpage"],["landis","LANDIS stratified"], - ["landowner","Landowner yields"],["faustmann","Faustmann rotation"]].map(([k,lbl])=>{ + ["landowner","Landowner yields"],["faustmann","Faustmann rotation"],["permanence","Permanence / risk"]].map(([k,lbl])=>{ const disabled = (k==="divergence" && !divergence) || (k==="stumpage" && !(stumpage && stumpage.series && stumpage.series[sel])) || (k==="landis" && !(landis && landis[sel])) || (k==="landowner" && !(landowner && landowner[sel])) || (k==="faustmann" && !(faustmann && faustmann[sel])) - || ((k==="engines"||k==="rd") && !series); + || ((k==="engines"||k==="rd") && !series) + || (k==="permanence" && !series); return ; })} @@ -1426,6 +1428,7 @@ export default function App(){ {(!aoi || researchOpen) && tab==="landis" && } {(!aoi || researchOpen) && tab==="landowner" && } {(!aoi || researchOpen) && tab==="faustmann" && } + {(!aoi || researchOpen) && tab==="permanence" && } {(!aoi || researchOpen) && (tab==="engines"||tab==="rd") && (<> {LANDIS_STATES.includes(sel) && (
diff --git a/src/PermanenceRisk.jsx b/src/PermanenceRisk.jsx new file mode 100644 index 0000000..39c18b9 --- /dev/null +++ b/src/PermanenceRisk.jsx @@ -0,0 +1,187 @@ +// Permanence / reversal-risk view. +// PERSEUS already ships the disturbance-exposed and mortality-stressed reserve +// scenarios in the API (v1.4). This view contrasts the passive no-harvest +// reserve against those stressed siblings to answer the question competitor +// tools (e.g. carbon-reversal-risk) center on — but on PERSEUS's own +// multi-engine footing: how durable is stored carbon under elevated +// disturbance / mortality, and where does a no-harvest reserve plateau or +// turn into a net source? +import { useMemo, useState } from "react"; + +const BUCKETS = { + base: "reserve (no harvest)", + dist: "reserve (no harvest, disturbance-exposed)", + mort: "reserve (no harvest, mortality-stressed)", +}; +const COL = { base:"#66c2a5", dist:"#e6a23c", mort:"#e05a5a" }; + +// carbon-stock metrics this view makes sense for, in preference order +const PREF = ["agc_live_total","agb_dry","agc_live_ag","bgc_live_total","vol_stem"]; + +// ensemble median of value-by-year across the engines in a bucket +function medianByYear(seriesArr){ + if(!seriesArr || !seriesArr.length) return []; + const byYr = {}; + seriesArr.forEach(s => (s.pts||[]).forEach(p => { + if(p[1]==null || 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, v.length]; + }); +} +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 peak = line => line.reduce((m,p)=>p[1]>m?p[1]:m, -Infinity); + +export default function PermanenceRisk({ series, state, meta, stateName }){ + // choose a carbon-stock metric that actually has the reserve buckets + const metric = useMemo(()=>{ + if(!series) return null; + for(const m of PREF){ + const node = series[m]; + if(node && node[BUCKETS.base] && (node[BUCKETS.dist] || node[BUCKETS.mort])) return m; + } + return null; + }, [series]); + + const [hl, setHl] = useState(null); + + const data = useMemo(()=>{ + if(!metric) return null; + const node = series[metric]; + const base = medianByYear(node[BUCKETS.base]); + const dist = medianByYear(node[BUCKETS.dist]); + const mort = medianByYear(node[BUCKETS.mort]); + if(!base.length) return null; + const endYr = base[base.length-1][0]; + const bEnd = base[base.length-1][1]; + const dEnd = dist.length ? valAt(dist, endYr) : null; + const mEnd = mort.length ? valAt(mort, endYr) : null; + // reversal = shortfall of the stressed reserve vs the passive reserve at horizon + const distGap = dEnd!=null ? bEnd - dEnd : null; + const mortGap = mEnd!=null ? bEnd - mEnd : null; + const distPct = (distGap!=null && bEnd) ? distGap/bEnd*100 : null; + const mortPct = (mortGap!=null && bEnd) ? mortGap/bEnd*100 : null; + // does the disturbance-exposed reserve turn into a net source? (end below peak) + const distPk = dist.length ? peak(dist) : null; + const distDraw = (distPk!=null && dEnd!=null) ? (distPk - dEnd) : null; + const distSource = (distDraw!=null && distPk) ? distDraw/distPk*100 : null; + return { base, dist, mort, endYr, bEnd, dEnd, mEnd, + distGap, mortGap, distPct, mortPct, distDraw, distSource, + nEng: (node[BUCKETS.base]||[]).length }; + }, [metric, series]); + + if(!series) return
No model series for this state yet.
; + if(!metric || !data) + return
Permanence scenarios (disturbance-exposed / mortality-stressed reserve) are not available for {stateName||state} yet. They ship with the v1.4 carbon buckets — ME, GA, IN, MN and the focal states carry them.
; + + const unit = (meta && meta.metrics && meta.metrics[metric] && meta.metrics[metric].unit) || "Tg C"; + const label = (meta && meta.metrics && meta.metrics[metric] && meta.metrics[metric].label) || metric; + + // ---- chart geometry ---- + const W=560,H=300,L=52,R=120,T=16,B=30; + const lines = [["base",data.base],["dist",data.dist],["mort",data.mort]].filter(([,l])=>l.length); + const xs=[], ys=[]; + lines.forEach(([,l])=>l.forEach(p=>{ xs.push(p[0]); ys.push(p[1]); })); + const x0=Math.min(...xs), x1=Math.max(...xs); + let y0=0, y1=Math.max(...ys)*1.05||1; + const range=(y1-y0)||1, rawStep=range/4, mag=Math.pow(10,Math.floor(Math.log10(rawStep))); + const norm=rawStep/mag, step=(norm<1.5?1:norm<3?2:norm<7?5:10)*mag; + y1=Math.ceil(y1/step-1e-9)*step; + const X=v=>L+(v-x0)/((x1-x0)||1)*(W-L-R); + const Y=v=>(H-B)-(v-y0)/((y1-y0)||1)*(H-T-B); + const path=l=>l.map((p,k)=>(k?"L":"M")+X(p[0]).toFixed(1)+" "+Y(p[1]).toFixed(1)).join(" "); + const yticks=[]; for(let v=0;v<=y1+step*1e-6;v+=step) yticks.push(+v.toFixed(6)); + const xticks=[]; { const span=x1-x0, rs=span/4, xm=Math.pow(10,Math.floor(Math.log10(rs||1))); + const xn=(rs||1)/xm, xstep=Math.max(1,(xn<1.5?1:xn<3?2:xn<7?5:10)*xm); + for(let t=Math.ceil(x0/xstep)*xstep;t<=x1+1e-6;t+=xstep) xticks.push(t); } + + // reversal gap polygon (between base and disturbance-exposed) + let gapPoly=null; + if(data.dist.length){ + const up=data.base.map((p,k)=>(k?"L":"M")+X(p[0]).toFixed(1)+" "+Y(p[1]).toFixed(1)).join(" "); + const dn=data.base.slice().reverse().map(p=>"L"+X(p[0]).toFixed(1)+" "+Y(valAt(data.dist,p[0])).toFixed(1)).join(" "); + gapPoly=up+" "+dn+" Z"; + } + + const verdict = (()=>{ + 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); + const sourceNote = (ds!=null && ds>=8) ? ` The disturbance-exposed reserve peaks then draws down ${ds.toFixed(0)}% by ${data.endYr}, so it plateaus or turns into a partial net source rather than a durable sink.` : ""; + if(hi) + return { t:"Reversal risk: high", d:`The disturbance-exposed reserve ends ${dp!=null?dp.toFixed(0):"—"}% below the passive reserve at ${data.endYr}.${sourceNote} Passive storage here is strongly conditional on disturbance staying near historical rates.`, c:"#e05a5a" }; + if(mod) + return { t:"Reversal risk: moderate", d:`The disturbance-exposed reserve ends ${dp!=null?dp.toFixed(0):"—"}% below the passive reserve at ${data.endYr}.${sourceNote} Stored carbon is meaningfully sensitive to elevated disturbance.`, c:"#e6a23c" }; + return { t:"Reversal risk: lower", d:`The reserve holds most of its carbon under the stressed scenarios (within ${Math.max(dp||0,data.mortPct||0).toFixed(0)}% at ${data.endYr}). Durability is comparatively robust here.`, c:"#66c2a5" }; + })(); + + const fmt=v=> v==null?"—": (Math.abs(v)>=100? v.toFixed(0): v.toFixed(1)); + + return ( +
+
+ Permanence & reversal risk — {stateName||state} + {label} · ensemble median of {data.nEng} reserve engine{data.nEng===1?"":"s"} · {unit} +
+ +
+
+
{verdict.t}
+
{verdict.d}
+
+
+ +
+ {[["Passive reserve @"+data.endYr, fmt(data.bEnd), unit, COL.base], + ["Disturbance-exposed", fmt(data.dEnd), data.distPct!=null?`▼ ${data.distPct.toFixed(0)}%`:"", COL.dist], + ["Mortality-stressed", fmt(data.mEnd), data.mortPct!=null?`▼ ${data.mortPct.toFixed(0)}%`:"", COL.mort]].map((c,i)=>( +
+
{c[0]}
+
{c[1]} {c[2]}
+ {c[3]!==COL.base && c[2] &&
{c[2]}
} +
+ ))} +
+ + + {yticks.map((v,i)=>( + + {v>=1000?(v/1000).toFixed(1)+"k":v.toFixed(0)} + ))} + {xticks.map((t,i)=>({Math.round(t)}))} + {unit} + {gapPoly && } + {lines.map(([k,l])=>( + setHl(k)} onMouseLeave={()=>setHl(null)} + style={{cursor:"pointer"}}/> + ))} + {/* end labels */} + {lines.map(([k,l])=>{ + const last=l[l.length-1]; + const txt={base:"passive reserve",dist:"disturbance-exposed",mort:"mortality-stressed"}[k]; + return + + {txt} + ; + })} + + +
+ Reversal risk is the shortfall of a stressed no-harvest reserve against the passive reserve at {data.endYr}. The disturbance-exposed band spans historical / 2× / 3× disturbance frequency (FIA COND + GRM grounded); mortality-stressed elevates background mortality. Hover a line to isolate. Unlike single-model reversal tools, this is the cross-engine reserve median, so the risk read carries PERSEUS's multi-model spread. +
+
+ ); +}