Skip to content

Commit a1810a0

Browse files
committed
SILC v8-v10: AGM MORTCAL year-100, FVS-NE + OSM-ACD comparison, refined inputs
Key additions in calibration/output/comparisons_overstory/: 10 year empirical CFI scorecard (silc_cfi/): - Long horizon CFI scorecard at 10 yr horizon (n=6 routine pairs) - AGM MORTCAL cuts BA bias from +18.5% to +7.8%, sawlog BdFt R^2 0.84 - Mechanism decomposition figure (under mortality plus over growth) - Triple-check verification: pipeline reproduces to 1e-13 100 year strata projections: - AcadianGY MORTCAL=TRUE on 11 byStrata stands (Cardinal 11076533) - FVS-NE default + calibrated on same stands (Cardinal 11078908) - OSM-ACD 100 yr with refined BGI=3902 (Cardinal 11080104, 6 of 11 stands) - CFI plot backfill for Cedar A+B and Mixedwood A+B (Cardinal 11077925) - Year 100 BA: AGM MORTCAL 98-108, FVS-NE cal 125-225, OSM-ACD 172-221 Refined inputs from SILC CFI v3 lat/long (46.4628 N, 68.4253 W, Davistown): - BGI: 3000 to 3902 (ME_BGI_V1.tif raster sample) - CSI: 12.0 to 15.78 m (CSI_2030.tif raster sample) - FVS-NE site index: 42 to 35 ft (empirical from dominant heights) Deliverables: - SILC_4Model_Benchmark_v10_Weiskittel.pdf (18 slides, operational deck) - SILC_4Model_Benchmark_Report_v8_Weiskittel.docx (companion report) - silc_strata_5x2_AGM_MORTCAL_BA.png (100 yr trajectory figure) - silc_cfi_long_mechanism.png, silc_cfi_long_mortcal_compare.png - All raw trajectory CSVs and aggregated cell tables Operational recommendation: AGM with MORTCAL on anchors the long horizon floor at 35 to 39 cords/ac; FVS-NE calibrated brackets the upper bound at 44 to 70 cords/ac; OSM-ACD provides additional cross check.
1 parent 16ece54 commit a1810a0

92 files changed

Lines changed: 13366 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env python3
2+
"""lookup_bgi_csi.py - Extract BGI and CSI from rasters at SILC CFI lat/long
3+
and the 11 byStrata stand locations on Cardinal.
4+
Runs on Cardinal."""
5+
import os, sys
6+
try:
7+
import rasterio
8+
from rasterio.warp import transform as rio_transform
9+
except Exception:
10+
print("rasterio missing; install with: pip install --user rasterio")
11+
sys.exit(1)
12+
import pandas as pd
13+
from pyproj import Transformer
14+
15+
BGI_TIF = os.path.expanduser("~/raster_layers/bgi/ME_BGI_V1.tif")
16+
# Find CSI raster
17+
import glob
18+
CSI_TIFS = glob.glob(os.path.expanduser("~/raster_layers/csi/**/*.tif"), recursive=True)
19+
print("BGI:", BGI_TIF, "exists:", os.path.exists(BGI_TIF))
20+
print("CSI:", CSI_TIFS)
21+
22+
def sample_raster(path, lat, lon):
23+
with rasterio.open(path) as src:
24+
# transform lat/long (EPSG:4326) to raster CRS
25+
xs, ys = rio_transform("EPSG:4326", src.crs, [lon], [lat])
26+
try:
27+
v = next(src.sample([(xs[0], ys[0])]))[0]
28+
return float(v) if v is not None else None
29+
except StopIteration:
30+
return None
31+
32+
# === SILC CFI: single approximate location ===
33+
print("\n=== SILC CFI plots (approximate lat 46.4628, lon -68.4253) ===")
34+
bgi_cfi = sample_raster(BGI_TIF, 46.4628, -68.4253)
35+
print(f" BGI: {bgi_cfi}")
36+
for c in CSI_TIFS[:3]:
37+
v = sample_raster(c, 46.4628, -68.4253)
38+
print(f" CSI from {os.path.basename(c)}: {v}")
39+
40+
# === byStrata stands: from StandInit lat/long if any ===
41+
print("\n=== byStrata stands ===")
42+
si = pd.read_csv(os.path.expanduser("~/silc_strata/Acadian_Matrix_StandInit_2023.csv"))
43+
# Stand init has Climate Site Index already
44+
print(si[["STAND_ID","INV_PLOT_SIZE","ClimateSiteIndexMeters","ElevationMeters"]].to_string(index=False))
45+
print("\nNote: byStrata stands have CSI already in StandInit (ClimateSiteIndexMeters column)")

calibration/output/comparisons_overstory/osm_strata_StandList.csv

Lines changed: 121 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#!/usr/bin/env python3
2+
"""run_fvs_strata_100yr.py
3+
=====================================================================
4+
Run FVS-NE (default + calibrated) for 100 years on the 11 SILC
5+
byStrata stands, starting from the GrownDB year-2023 snapshot
6+
(matches the AGM MORTCAL 100-yr runner so trajectories are comparable).
7+
NUMCYCLE=20 with TIMEINT=5 (i.e. 5-yr cycles x 20 = 100 yr).
8+
"""
9+
from __future__ import annotations
10+
import os, sys, sqlite3, subprocess, tempfile
11+
import pandas as pd
12+
import numpy as np
13+
14+
PROJECT_ROOT = os.environ.get("FVS_PROJECT_ROOT", "/users/PUOM0008/crsfaaron/fvs-modern")
15+
FVS_LIB_DIR = os.environ.get("FVS_LIB_DIR", os.path.join(PROJECT_ROOT, "lib"))
16+
sys.path.insert(0, "/users/PUOM0008/crsfaaron/silc_cfi")
17+
from run_fvs_on_cfi import (build_standinit, KEYFILE, calibrated_keywords)
18+
19+
# AGM alpha species code to FIA SPCD (Acadian / Maine region)
20+
AGY_TO_SPCD = {
21+
"BF": 12, "RS": 97, "PB": 375, "YB": 371, "RM": 316,
22+
"SM": 318, "WA": 541, "RO": 833, "BS": 95, "WS": 91,
23+
"JP": 105, "RP": 125, "WP": 129, "EH": 261, "WC": 241,
24+
"BC": 531, "QA": 746, "GB": 934, "BA": 543, "AB": 531,
25+
"ST": 951, "TA": 547, "OH": 998, "OS": 299, "HW": 998,
26+
"PC": 117, "PR": 125, "AS": 543, "BT": 951, "EC": 241,
27+
"HH": 701, "NS": 94, "RB": 372, "RN": 94, "BP": 98,
28+
"SW": 998, "SB": 95, "TL": 547, "WB": 375,
29+
}
30+
31+
def build_treeinit_strata(td, sid):
32+
"""GrownDB year-2023 snapshot already has imperial DBH (in), Ht (ft),
33+
and TPA (per-acre). Map alpha species to FIA SPCD."""
34+
rows = []
35+
for i, r in td.iterrows():
36+
spcd = AGY_TO_SPCD.get(str(r["Species"]), 998)
37+
dbh = float(r["DBH"])
38+
if not (np.isfinite(dbh) and dbh > 0):
39+
continue
40+
ht = float(r["Ht"]) if pd.notna(r["Ht"]) and r["Ht"] > 0 else 0.0
41+
tpa = float(r["TPA"])
42+
rows.append({
43+
"stand_id": sid,
44+
"plot_id": 1,
45+
"tree_id": i + 1,
46+
"tree_count": round(tpa, 6),
47+
"species": spcd,
48+
"diameter": round(dbh, 3),
49+
"ht": round(ht, 1),
50+
"crratio": 40,
51+
})
52+
return pd.DataFrame(rows)
53+
54+
def run_one_100yr(sid, tree_df, inv_year, variant, config):
55+
"""Run FVS for 20 cycles of 5 yr each = 100 yr horizon."""
56+
binary = os.path.join(FVS_LIB_DIR, f"FVS{variant.lower()}")
57+
with tempfile.TemporaryDirectory() as d:
58+
db = os.path.join(d, "FVS_Data.db")
59+
con = sqlite3.connect(db)
60+
build_standinit(sid, inv_year, variant).to_sql("fvs_standinit", con, if_exists="replace", index=False)
61+
tree_df.to_sql("fvs_treeinit", con, if_exists="replace", index=False)
62+
con.close()
63+
calib = calibrated_keywords(variant) if config == "calibrated" else "** DEFAULT"
64+
key = os.path.join(d, "strata.key")
65+
open(key, "w").write(KEYFILE.format(sid=sid, db=db, clen=5, ncyc=20, calib=calib))
66+
try:
67+
subprocess.run([binary, f"--keywordfile={key}"], cwd=d, capture_output=True, timeout=600)
68+
except subprocess.TimeoutExpired:
69+
return None
70+
try:
71+
df = pd.read_sql_query("SELECT * FROM FVS_Summary2", sqlite3.connect(db))
72+
df["variant"] = variant.upper()
73+
df["config"] = config
74+
return df
75+
except Exception:
76+
return None
77+
78+
def main():
79+
wd = os.getcwd()
80+
gr = pd.read_csv(os.path.join(wd, "GrownDB_byStrata_ALL.csv"))
81+
gr2023 = gr[(gr.Year == 2023) & gr.DBH.notna() & gr.TPA.notna() & (gr.TPA > 0)]
82+
stand_ids = sorted(gr2023["StandID"].unique())
83+
print(f"Running FVS-NE 100-yr on {len(stand_ids)} byStrata stands")
84+
85+
rows = []
86+
for sid in stand_ids:
87+
td = gr2023[gr2023.StandID == sid]
88+
tree_df = build_treeinit_strata(td, sid)
89+
for variant in ("ne",): # FVS-NE only
90+
for config in ("default", "calibrated"):
91+
df = run_one_100yr(sid, tree_df, 2023, variant, config)
92+
if df is None or len(df) == 0:
93+
print(f" {sid} {variant} {config}: FAILED")
94+
continue
95+
df["StandID"] = sid
96+
rows.append(df)
97+
last = df.iloc[-1]
98+
print(f" {sid} {variant} {config}: yr{int(last.get('Year', 2123))} BA={last.get('BA', 0):.1f} TPA={last.get('Tpa', 0):.0f} Cords={last.get('MCuFt', 0)/79:.1f}")
99+
100+
out = pd.concat(rows, ignore_index=True) if rows else pd.DataFrame()
101+
out.to_csv(os.path.join(wd, "silc_strata_100yr_fvsne_results.csv"), index=False)
102+
print(f"\nWrote {len(out)} rows for {out['StandID'].nunique() if len(out) else 0} stands")
103+
104+
if __name__ == "__main__":
105+
main()
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env python3
2+
"""run_fvs_strata_100yr_v2.py
3+
v2: refined site index. Default SI=42 (balsam fir) was too optimistic
4+
for these mature spruce/fir/mixed stands. Empirically observed dominant
5+
heights of 25-35 ft on the SILC CFI at ~30-50 yr stand age put true SI
6+
at ~35 ft. Lowering to SI=35 gives less optimistic growth projections.
7+
"""
8+
from __future__ import annotations
9+
import os, sys, sqlite3, subprocess, tempfile
10+
import pandas as pd, numpy as np
11+
12+
PROJECT_ROOT = os.environ.get("FVS_PROJECT_ROOT", "/users/PUOM0008/crsfaaron/fvs-modern")
13+
FVS_LIB_DIR = os.environ.get("FVS_LIB_DIR", os.path.join(PROJECT_ROOT, "lib"))
14+
sys.path.insert(0, "/users/PUOM0008/crsfaaron/silc_cfi")
15+
from run_fvs_on_cfi import KEYFILE, calibrated_keywords
16+
17+
SITE_INDEX_REFINED = 35 # was 42; empirical estimate for mature spruce/fir
18+
19+
# Override build_standinit to use refined SI
20+
def build_standinit_v2(sid, inv_year, variant):
21+
return pd.DataFrame([{
22+
"stand_id": sid, "variant": variant.upper(), "inv_year": int(inv_year),
23+
"latitude": 46.4628, "longitude": -68.4253, "region": 9,
24+
"forest": 0, "district": 0,
25+
"basal_area_factor": 0.0, "inv_plot_size": 1.0, "brk_dbh": 999.0,
26+
"num_plots": 1,
27+
"age": 60, "aspect": 0, "slope": 5, "elevft": 1000,
28+
"site_species": 12, "site_index": SITE_INDEX_REFINED,
29+
"state": 23, "county": 21,
30+
"forest_type": 121, "sam_wt": 1.0,
31+
}])
32+
33+
AGY_TO_SPCD = {
34+
"BF": 12, "RS": 97, "PB": 375, "YB": 371, "RM": 316,
35+
"SM": 318, "WA": 541, "RO": 833, "BS": 95, "WS": 91,
36+
"JP": 105, "RP": 125, "WP": 129, "EH": 261, "WC": 241,
37+
"BC": 531, "QA": 746, "GB": 934, "BA": 543, "AB": 531,
38+
"ST": 951, "TA": 547, "OH": 998, "OS": 299, "HW": 998,
39+
"PC": 117, "PR": 125, "AS": 543, "BT": 951, "EC": 241,
40+
"HH": 701, "NS": 94, "RB": 372, "RN": 94, "BP": 98,
41+
"SW": 998, "SB": 95, "TL": 547, "WB": 375,
42+
}
43+
44+
def build_treeinit_strata(td, sid):
45+
rows = []
46+
for i, r in td.iterrows():
47+
spcd = AGY_TO_SPCD.get(str(r["Species"]), 998)
48+
dbh = float(r["DBH"])
49+
if not (np.isfinite(dbh) and dbh > 0): continue
50+
ht = float(r["Ht"]) if pd.notna(r["Ht"]) and r["Ht"] > 0 else 0.0
51+
tpa = float(r["TPA"])
52+
rows.append({"stand_id": sid, "plot_id": 1, "tree_id": i + 1,
53+
"tree_count": round(tpa, 6), "species": spcd,
54+
"diameter": round(dbh, 3), "ht": round(ht, 1), "crratio": 40})
55+
return pd.DataFrame(rows)
56+
57+
def run_one_100yr(sid, tree_df, inv_year, variant, config):
58+
binary = os.path.join(FVS_LIB_DIR, f"FVS{variant.lower()}")
59+
with tempfile.TemporaryDirectory() as d:
60+
db = os.path.join(d, "FVS_Data.db")
61+
con = sqlite3.connect(db)
62+
build_standinit_v2(sid, inv_year, variant).to_sql("fvs_standinit", con, if_exists="replace", index=False)
63+
tree_df.to_sql("fvs_treeinit", con, if_exists="replace", index=False)
64+
con.close()
65+
calib = calibrated_keywords(variant) if config == "calibrated" else "** DEFAULT"
66+
key = os.path.join(d, "strata.key")
67+
open(key, "w").write(KEYFILE.format(sid=sid, db=db, clen=5, ncyc=20, calib=calib))
68+
try:
69+
subprocess.run([binary, f"--keywordfile={key}"], cwd=d, capture_output=True, timeout=600)
70+
except subprocess.TimeoutExpired:
71+
return None
72+
try:
73+
df = pd.read_sql_query("SELECT * FROM FVS_Summary2", sqlite3.connect(db))
74+
df["variant"] = variant.upper(); df["config"] = config
75+
return df
76+
except Exception:
77+
return None
78+
79+
def main():
80+
wd = os.getcwd()
81+
gr = pd.read_csv(os.path.join(wd, "GrownDB_byStrata_ALL.csv"))
82+
gr2023 = gr[(gr.Year == 2023) & gr.DBH.notna() & gr.TPA.notna() & (gr.TPA > 0)]
83+
stand_ids = sorted(gr2023["StandID"].unique())
84+
print(f"Running FVS-NE 100-yr v2 (SI={SITE_INDEX_REFINED}) on {len(stand_ids)} byStrata stands")
85+
rows = []
86+
for sid in stand_ids:
87+
td = gr2023[gr2023.StandID == sid]
88+
tree_df = build_treeinit_strata(td, sid)
89+
for variant in ("ne",):
90+
for config in ("default", "calibrated"):
91+
df = run_one_100yr(sid, tree_df, 2023, variant, config)
92+
if df is None or len(df) == 0:
93+
print(f" {sid} {variant} {config}: FAILED"); continue
94+
df["StandID"] = sid
95+
rows.append(df)
96+
last = df.iloc[-1]
97+
print(f" {sid} {variant} {config}: yr2123 BA={last.get('BA',0):.1f} Cords={last.get('MCuFt',0)/79:.1f}")
98+
out = pd.concat(rows, ignore_index=True) if rows else pd.DataFrame()
99+
out.to_csv(os.path.join(wd, "silc_strata_100yr_fvsne_si35_results.csv"), index=False)
100+
print(f"\nWrote {len(out)} rows for {out['StandID'].nunique() if len(out) else 0} stands")
101+
102+
if __name__ == "__main__":
103+
main()
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
#!/usr/bin/env python3
2+
"""run_osm_strata_100yr.py - OSM-ACD 100-yr driver for SILC byStrata
3+
stands. Uses GrownDB year-2023 snapshot for the starting tree list
4+
(matches AGM/FVS-NE), with refined per-stand BGI=3902 from ME_BGI_V1
5+
raster at Davistown lat/long and CSI from StandInit."""
6+
import sqlite3, subprocess
7+
from io import StringIO
8+
from pathlib import Path
9+
import numpy as np, pandas as pd
10+
11+
OSM_BINARY = Path.home() / "OSM/v2.26.1/OSMv2.26.1_Linux64/OSM.ConsoleApp"
12+
BGI_REFINED = 3902 # from ME_BGI_V1.tif sampled at lat 46.4628, lon -68.4253
13+
BA_PER_TREE_CM = 0.00007854
14+
FT2_AC_PER_M2_HA = 4.35
15+
ACRES_PER_HA = 2.4710538147
16+
N_YEARS = 100
17+
18+
OSM_REMAP = {"PB": "WB", "TA": "TL", "PC": "PR"}
19+
acadgy_species = {"AB","AS","BA","BC","BF","BP","BS","BT","EC","EH","GA","GB",
20+
"HH","HW","JP","NS","OH","OS","PB","PC","PR","QA","RB","RM",
21+
"RN","RO","RP","RS","SB","SM","ST","SW","TA","WA","WC","WP",
22+
"WS","YB"}
23+
24+
def to_osm_sp(sp):
25+
s = str(sp)
26+
if s in acadgy_species:
27+
return OSM_REMAP.get(s, s)
28+
return "OS"
29+
30+
def build_inputs(stand_ids, gr2023, si_lookup, out_dir):
31+
out_dir.mkdir(parents=True, exist_ok=True)
32+
sqlite_path = out_dir / "osm_strata.sqlite"
33+
osmc_path = out_dir / "osm_strata.osmc"
34+
stand_csv = out_dir / "osm_strata_StandList.csv"
35+
tree_csv = out_dir / "osm_strata_TreeList.csv"
36+
if sqlite_path.exists(): sqlite_path.unlink()
37+
38+
sid_map = {}
39+
stand_rows = []
40+
tree_rows = []
41+
for i, sid in enumerate(stand_ids):
42+
n = i + 1
43+
sid_map[sid] = n
44+
stand_rows.append((n, 1, "ME", "None", BGI_REFINED, 0))
45+
td = gr2023[gr2023["StandID"] == sid]
46+
for _, r in td.iterrows():
47+
dbh = float(r["DBH"]) if pd.notna(r["DBH"]) else 0
48+
if dbh <= 0: continue
49+
dbh_cm = dbh * 2.54
50+
ht = float(r["Ht"]) if pd.notna(r["Ht"]) and r["Ht"] > 0 else 0
51+
if ht <= 0:
52+
ht_ft = max(6, 4.27 + 82 * (1 - np.exp(-0.04 * dbh_cm)))
53+
ht_m = ht_ft * 0.3048
54+
else:
55+
ht_m = ht * 0.3048
56+
expf_ha = float(r["TPA"]) * ACRES_PER_HA
57+
sp = to_osm_sp(str(r["Species"]))
58+
tree_rows.append((n, sp, dbh_cm, ht_m, expf_ha))
59+
60+
con = sqlite3.connect(str(sqlite_path)); cur = con.cursor()
61+
cur.execute("CREATE TABLE OSM_StandList (SurveyID INT, Plots INT, Zone TEXT, "
62+
"Management TEXT, BGI INT, PoorSite SMALLINT)")
63+
cur.execute("CREATE TABLE OSM_TreeList (SurveyID INT, Species TEXT, DBH REAL, "
64+
"HT REAL, Stems REAL)")
65+
cur.execute("CREATE INDEX idx_tl_sid ON OSM_TreeList(SurveyID)")
66+
cur.executemany("INSERT INTO OSM_StandList VALUES (?,?,?,?,?,?)", stand_rows)
67+
cur.executemany("INSERT INTO OSM_TreeList (SurveyID, Species, DBH, HT, Stems) VALUES (?,?,?,?,?)", tree_rows)
68+
con.commit(); con.close()
69+
70+
lines = ["SIMULATION", f" YEARS {N_YEARS}", " YPC 5", "",
71+
f'INPUTS.SOURCE "{sqlite_path}"', "", "OUTPUTS", " Messages FALSE",
72+
" StandSummary.ConsoleOn FALSE",
73+
f' StandSummary.FilePath "{stand_csv}"',
74+
f' TreeList.FilePath "{tree_csv}"', "", "SIMULATION.Scenario BASE"]
75+
for sid in sorted(sid_map.values()): lines.append(f"SIMULATE {sid}")
76+
osmc_path.write_text("\n".join(lines) + "\n")
77+
return sqlite_path, osmc_path, stand_csv, tree_csv, sid_map
78+
79+
def parse_stand(stand_csv):
80+
raw = Path(stand_csv).read_text(errors="replace").replace("\x00", "")
81+
df = pd.read_csv(StringIO(raw))
82+
def col(*names):
83+
for n in names:
84+
if n in df.columns: return n
85+
c_sid = col("SurveyID","Survey_ID","Id")
86+
c_yr = col("Year","Yr","Cycle","Age")
87+
c_ba = col("BA","BasalArea","BasalArea_m2ha","GBA")
88+
c_tph = col("Trees","TPH","Stems","Stems_per_ha","Density")
89+
c_qmd = col("QMD","Qmd","Qmd_cm")
90+
df = df[df[c_sid].notna()].copy()
91+
df["SurveyID"] = df[c_sid].astype(float).astype(int)
92+
df["yr_off"] = df.groupby("SurveyID")[c_yr].transform(
93+
lambda s: (s.astype(float) - s.astype(float).min()).round().astype(int))
94+
df["BA_m2ha"] = df[c_ba].astype(float)
95+
df["TPH_ha"] = df[c_tph].astype(float)
96+
df["QMD_cm"] = (df[c_qmd].astype(float) if c_qmd
97+
else np.sqrt((df["BA_m2ha"] / df["TPH_ha"].replace(0, np.nan)) / BA_PER_TREE_CM))
98+
return df[["SurveyID","yr_off","BA_m2ha","TPH_ha","QMD_cm"]]
99+
100+
def main():
101+
wd = Path.cwd()
102+
gr = pd.read_csv(wd / "GrownDB_byStrata_ALL.csv")
103+
gr2023 = gr[(gr["Year"] == 2023) & gr["DBH"].notna() & gr["TPA"].notna() & (gr["TPA"] > 0)]
104+
si = pd.read_csv(wd / "Acadian_Matrix_StandInit_2023.csv")
105+
si_lookup = dict(zip(si["STAND_ID"], si["ClimateSiteIndexMeters"]))
106+
stand_ids = sorted(gr2023["StandID"].unique())
107+
print(f"Running OSM-ACD 100-yr on {len(stand_ids)} byStrata stands, BGI={BGI_REFINED}")
108+
109+
out_dir = wd / "osm_strata_work"
110+
sqlite_path, osmc_path, stand_csv, tree_csv, sid_map = build_inputs(
111+
stand_ids, gr2023, si_lookup, out_dir)
112+
print(f"Built inputs for {len(sid_map)} stands")
113+
res = subprocess.run([str(OSM_BINARY), "Acadian", str(osmc_path)],
114+
capture_output=True, text=True, timeout=1800)
115+
if res.returncode != 0:
116+
print(f"OSM failed: {res.stderr[-500:]}")
117+
raise SystemExit(1)
118+
sp = parse_stand(stand_csv)
119+
print(f"Parsed {len(sp)} stand-year rows")
120+
121+
# Convert to imperial and map back to StandID
122+
sid_df = pd.DataFrame([(s, sid) for sid, s in sid_map.items()],
123+
columns=["StandID","SurveyID"])
124+
sp = sp.merge(sid_df, on="SurveyID", how="left")
125+
sp["Year"] = 2023 + sp["yr_off"]
126+
sp["BA"] = sp["BA_m2ha"] * FT2_AC_PER_M2_HA
127+
sp["Tpa"] = sp["TPH_ha"] / ACRES_PER_HA
128+
sp["QMD"] = sp["QMD_cm"] / 2.54
129+
sp = sp[["StandID","Year","BA","Tpa","QMD"]]
130+
sp.to_csv(wd / "silc_strata_100yr_osmacd_results.csv", index=False)
131+
print(f"Wrote silc_strata_100yr_osmacd_results.csv: {len(sp)} rows")
132+
133+
if __name__ == "__main__":
134+
main()

0 commit comments

Comments
 (0)