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/FaustmannRotation.jsx b/src/FaustmannRotation.jsx index 7eb237d..0bd54e7 100644 --- a/src/FaustmannRotation.jsx +++ b/src/FaustmannRotation.jsx @@ -52,9 +52,21 @@ export default function FaustmannRotation({ data, state }){
Faustmann optimal-rotation runs cover {data.meta.state} only.
; const rows = rowsAll.filter(r => (owner==="all"||r.owner===owner) && (treatment==="all"||r.treatment===treatment)); - // carbon-floor effect: mean R_opt with vs without a floor, same filter + // carbon-floor effect: rotation, soil expectation value (SEV) and standing + // biomass with vs without a carbon floor, same filter. The floor is the cost + // of carbon made legible: how much soil expectation value a landowner forgoes + // to carry extra standing carbon, and how much longer the rotation runs. const noFloor = rows.filter(r=>r.carbon_floor===0), floor = rows.filter(r=>r.carbon_floor>0); - const meanR = a => a.length ? (a.reduce((s,r)=>s+r.R_opt,0)/a.length) : null; + const meanOf = (a,k) => a.length ? (a.reduce((s,r)=>s+(r[k]||0),0)/a.length) : null; + const meanR = a => meanOf(a,"R_opt"); + const sev0 = meanOf(noFloor,"sev_opt"), sevF = meanOf(floor,"sev_opt"); + const agb0 = meanOf(noFloor,"mean_agb"), agbF = meanOf(floor,"mean_agb"); + const sevForegone = (sev0!=null && sevF!=null) ? (sev0 - sevF) : null; + const pctForegone = (sevForegone!=null && sev0) ? sevForegone/sev0*100 : null; + const agbGain = (agb0!=null && agbF!=null) ? (agbF - agb0) : null; + // implied cost per Mg of extra standing biomass ($/ac forgone per Mg/ha gained); + // labeled as a rough ratio, not a market carbon price. + const costPerAGB = (sevForegone!=null && agbGain) ? sevForegone/agbGain : null; return (
@@ -76,11 +88,24 @@ export default function FaustmannRotation({ data, state }){ {[...new Set(rows.map(r=>r.ft))].map(ft => {ft})}
- {meanR(noFloor)!=null && meanR(floor)!=null && ( -
- Carbon-floor effect: mean optimal rotation rises from - {meanR(noFloor).toFixed(0)} yr (no floor) to - {meanR(floor).toFixed(0)} yr with a carbon floor (filled, white-edged points). + {sev0!=null && sevF!=null && ( +
+
Cost of the carbon floor
+
+ {[["Rotation", `${meanR(noFloor).toFixed(0)} → ${meanR(floor).toFixed(0)} yr`, `+${(meanR(floor)-meanR(noFloor)).toFixed(0)} yr longer`], + ["Soil expectation value", `$${sev0.toFixed(0)} → $${sevF.toFixed(0)}/ac`, sevForegone!=null?`−$${sevForegone.toFixed(0)}/ac forgone${pctForegone!=null?` (${pctForegone.toFixed(0)}%)`:""}`:""], + ["Standing biomass", `${agb0!=null?agb0.toFixed(1):"—"} → ${agbF!=null?agbF.toFixed(1):"—"} Mg/ha`, agbGain!=null?`+${agbGain.toFixed(1)} Mg/ha carried`:""] + ].map((c,i)=>( +
+
{c[1]}
+
{c[0]}
+
{c[2]}
+
))} +
+ {costPerAGB!=null && isFinite(costPerAGB) && ( +
+ Implied tradeoff: roughly ${costPerAGB.toFixed(0)}/ac of soil expectation value forgone per additional Mg/ha of standing biomass carried under the floor (a within-model ratio for this owner/treatment, not a market carbon price). +
)}
)}
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. +
+
+ ); +}