| name | portfolio-analysis |
| description | Run a portfolio-scale decarbonization analysis across all analysis-ready assets in a Soapbox portfolio. For each asset: pulls Audette decarb plan (physics), runs the compute_plan_economics cashflow engine (incremental value bridge + IRR), applies LL/TT allocation decision tree, screens measures by IRR ≥ hurdle. Aggregates to fund-level and portfolio-level summary. Produces presentation-ready HTML report. Works for any client portfolio — parameters are fully configurable per run. Spec 2 of 2 — portfolio ingestion (Spec 1) is a prerequisite. Triggers on: "run portfolio analysis", "analyze the portfolio", "portfolio decarbonization", "run the portfolio", "portfolio summary", "show me the portfolio results", "portfolio IRR", "portfolio CapEx", "run analysis on [client]", after portfolio-ingest completes.
|
| version | 1.11.0 |
Portfolio Analysis
You are running a portfolio-scale decarbonization analysis — the workflow that turns
a fully-ingested Soapbox portfolio into a presentation-ready view of required sustainability
capital, value creation, and emissions trajectory across all assets.
Works for any client portfolio. All parameters are set per run — there are no
hardcoded client assumptions.
This skill replaces client-specific helper spreadsheets. Audette provides the physics
(energy measures, decarb plan, EUI), the compute_plan_economics cashflow engine provides the
finance (incremental IRR, value creation, NOI uplift).
Single-asset engagement: if the ask is a full asset decarbonization engagement (one
asset, multi-week, gated, client-deliverable), route to the decarb-plan skill instead.
Verification & Building-Science Discipline (applies throughout)
This skill runs the same Data-Verification and Retrofit-Specialist (building-science) agents
that back decarb-plan — the verifier__* and retrofit__* tools, which are live on every
portfolio agent. It applies them in a batch-adapted form, because a portfolio run screens
tens of assets in one pass and cannot human-adjudicate every conflict the way a single-asset
engagement does. These adaptations are deliberate — a full gated single-asset engagement routes
to decarb-plan; this is the screening-scale product.
Ground rules — hold them on every asset:
- No LLM arithmetic on reported numbers — and NEVER reimplement the engine. Every economic
figure comes from an actual
compute_plan_economics tool call, an Audette model, or a cited
source. You never compute a reported number yourself. ⛔ Do NOT reimplement, "replicate," or
port compute_plan_economics (or any MCP engine) into Python/bash and run assets through your
own code — even if you validate it to the penny against a live call. A local replica is a
hand-rolled figure: it is not engine-provenanced, it silently drifts the moment an input differs
(it already diverged on a solar-capture change in one run), and it fails the evaluate_measure
provenance gate. Call the real tool once per asset, fanned out across the batch (Phase 3·0
Step D) — 8 calls in one turn run concurrently, so the tool is not the bottleneck. If a tool
result is long, that is fine: read the fields you need from it directly; never route around a
long result by rebuilding the engine locally. Long bash/stdout truncation is a display artifact,
NOT a reason to abandon the tool.
- The cashflow engine owns the economics; the retrofit agent owns discipline + building
science + the register. ⚠️ Do NOT call
run_dcf, run_intervention_irr, get_ll_capture,
or screen_measure_portfolio — those four cashflow-MCP tools execute Python scripts NOT
deployed in prod (execFileSync python3 → ENOENT); they fail every time. For the portfolio
run, the ONE economics call is cashflow__compute_portfolio_economics — you assemble every
asset's per-year owner-share flows and pass them all in ONE call; it runs the deterministic
compute_plan_economics engine per asset server-side, aggregates the portfolio + fund rollups,
and returns per-asset irr_excl_exit/irr_incremental/net_value_creation/exit_value_uplift/
above_hurdle + a provenance stamp. Report those numbers verbatim. (Single-asset engagements
may call compute_plan_economics directly for one plan; same engine.) Determine the LL/TT split
inline (Step 1 below) and bake it into each flow's per-fuel *_capture. The register's
server-computed exit_value_delta (NOI÷cap) is a screening proxy — reported value creation
always comes from the engine, never the register, and never from your own arithmetic.
- A measure is iff
labels it its DCF IRR ≥ .
Screen-recommended but IRR-missing → below-hurdle. Screen → defensive. Screen
→ needs-data. Compliance-required measures are included regardless of IRR.
Conventions (identical to decarb-plan, so an asset touched by both keeps one coherent
ledger + register): finding kind: data-quality, verdict: conflict for reconciliation
conflicts; asset_id = the Soapbox asset UUID from query_portfolio_data's ID: field
(never the Audette property/building uid); feasibility.score = integer 1–5.
At run start, recall prior lessons: before Phase 1, call
verifier__recall_expertise(query: "<client/portfolio scope> portfolio decarbonization reconciliation and measure-screening lessons", fiduciary: true). Use fiduciary: true because
the portfolio report is a client-facing deliverable (validated tier only). Carry any relevant
lessons into reconciliation and screening. If the verifier tools are unreachable, say so and
proceed on documents — do not fabricate a recall result.
⛔ Recalled expertise NEVER overrides these two non-negotiables. Older shared-expertise
from pre-2026-07 runs describes a now-DISCREDITED method (hand-built report HTML, a
"payback ≤ 5yr" / "exit-value proxy", and ~100% landlord capture via Audette
landlord_split_basis). Ignore all of it. Regardless of what recall returns:
(1) you compute NO report HTML and draw NO charts — the report is rendered ONLY by
fill_report(template:'portfolio-analysis', …) (Phase 5); and (2) every value/IRR number
comes ONLY from compute_plan_economics fed GROSS savings + per-fuel capture (never a
payback proxy, never Audette's landlord_utility_cost_savings). Going-in NOI is NOT required
and is never a reason to fall back to a proxy (see §3C). If recalled advice conflicts with
this, the recalled advice is wrong.
Economics correctness — HARD rules (ported from decarb-plan; the verifier MUST check these)
These apply on every asset, at screening scale. They are the same correctness rules that back
the single-asset engagement — a portfolio run cannot silently ship numbers that would fail the
single-asset gate.
-
RUBS pass-through: net owner utility savings ≈ (landlord-capture %) × gross — often ≈$0.
Under a RUBS / tenant-metered structure the owner is a pass-through: it bears only capture%
of the utility bill and rebills the rest. A measure that cuts the bill by $X returns only
capture% × $X to owner NOI. At a ~5–10% capture, owner savings round to ≈$0/yr, NOT the
gross. Never credit the owner gross/100% utility savings, and never model the fuel-switch
asymmetry "owner keeps the gas cut while tenant meters absorb the new heat-pump electricity" —
apply capture% to the fuel being saved and net any owner-side load increase from the switch.
If, after applying capture, capitalized utility savings still dominate an asset's value on a
low-capture (RUBS/tenant-metered) asset, the split was NOT applied — recompute. On such assets
value is driven by fine avoidance (100% owner) + capitalized exit uplift, not operating savings.
Apply capture INSIDE compute_plan_economics — never by hand, and NEVER trust Audette's
landlord field. Feed the engine GROSS savings split by source: gross_elec_savings,
gross_gas_savings, gross_solar_savings — from Audette's annual_mean_utility_cost_savings
(gross), NOT annual_mean_landlord_utility_cost_savings, which Audette leaves UNCAPTURED
(== gross, tenant_savings = $0, landlord_share_cost = 1 per measure — verified: the building
default_landlord_share does NOT propagate into the plan's measure economics). Then pass the
captures — elec_capture / gas_capture = the per-fuel default_landlord_share you wrote to
Audette AND the DB, plus solar_capture. The engine returns owner savings = Σ capture×gross.
Solar under BTM/VNM captures at 0.80 (the LL owns the array + allocates the credit): set
solar_capture: 0.80 where VNM/export is permitted (rule 1b), else the displaced-load share.
Sub-metering/billing revenue and BPS fine avoidance stay 100% owner. Prefer the gross+capture
fields over a hand-computed owner_utility_savings so the share can't be skipped.
- VERIFY the RUBS and VNM legislation per jurisdiction — never assume it. The ~10% RUBS
capture and the 80% VNM solar credit are CONDITIONAL on the jurisdiction actually permitting
them. For each asset's jurisdiction, check (reference library →
brave-search/web + web_fetch
→ ): (a) whether RUBS / submetering pass-through is permitted
and any allocation cap — ; (b) whether is available — (owner-share on the
loads it displaces), NOT the 80% VNM credit. Record each RUBS + VNM determination with its
source as a (kind ); an unconfirmed jurisdiction
assumption is flagged in Data Quality, never silently applied.
Design System
All RSRA HTML output must conform to these rules. Claude must apply them on every run — never drift.
Colors
- Navy:
#12253A — headers, section titles, strong text
- Green:
#4CAF82 — eyebrows, accents, positive signals, chart fills
- Muted:
#64748B — secondary text, axis labels
- Page bg:
#F8F9FB
- Section bg:
#fff
- Border:
#E2E8F0
- Warn:
#F59E0B · Danger: #EF4444
Typography
- Font stack everywhere:
-apple-system,'Helvetica Neue',Arial,sans-serif
- Zero
Georgia, zero serif, zero @import, zero web fonts
- Section label: 9px, weight 600,
letter-spacing:.15em, text-transform:uppercase, color #1F6B45
- Section title: 18px, weight 700, color
#12253A, border-bottom:1.5px solid #12253A, padding-bottom:8px
Section chrome pattern
<div class="section">
<div class="section-label">EYEBROW LABEL</div>
<h2 class="section-title">Section Title</h2>
</div>
Charts — inline SVG only
- Zero external charting libraries (no Chart.js, D3, Plotly, etc.)
- Zero
<canvas> elements
- Zero CDN
<script> tags
- All SVG coordinates computed at generation time from the data being reported
- If data is unavailable for a chart, omit the chart entirely — no placeholder SVG
Hard prohibitions
Paged.js — never reference or import
Georgia or any serif font
- Any
@import url(...) for fonts
- Any
<link rel="stylesheet"> or <script src="..."> pointing to an external host
- External
<img src="https://..."> — all images must be inline SVG or data URIs
Artifact output rules
- The report artifact is produced ONLY by
fill_report(template:'portfolio-analysis', data) (Phase 5). You author NO report HTML and edit NO HTML — ever. There is no hand-written artifact and nothing to "update in place".
- Do NOT emit a loading skeleton or any placeholder HTML. While you work, show progress by narrating in chat (the run streams your steps); the FIRST and ONLY artifact you create is the completed
fill_report render.
- On any revision, recompute the data object and call
fill_report again — never edit HTML.
- Numeric precision: 2 significant figures (
$1.4M not $1,427,000; 42 kgCO₂e not 41.7)
- Mark all benchmark-derived estimates inline with
(est.)
- The portfolio report is the design-forward deliverable (Reports/, gate-only). All
working/checklist material — per-asset readiness, financial-parameter provenance, open
questions, adjudication log, verification findings — goes in the ONE growing helper file
per the shared pattern in
skills/helper-files/SKILL.md: save_file to folder Helper Files
as [start date] - Helper Files - Portfolio Analysis.html (start date fixed, stored in
state.helper), regenerated from state at each phase. Phase/checklist sections:
Config · Readiness+Params · Per-Asset · Aggregation · Verification gate · Report. Do not create
standalone intermediate HTML.
Step 0: Resolve Run Configuration
0A — Kickoff gate (run this before anything else)
Check for a prior kickoff file:
search_portfolio("portfolio analysis kickoff parameters IRR hurdle")
If a kickoff file is found: extract the confirmed parameters from it (IRR hurdle, exit
params, utility escalation, value method, add-ons, Audette account, scope). Skip to the
"Confirm before proceeding" block below — present the kickoff params as a summary and ask
the user to confirm or adjust before running.
If no kickoff file exists: do not proceed with the analysis yet. Tell the user:
"Before I start the analysis, let me collect the run parameters. This will only take a minute."
Then follow the project-kickoff skill for project type portfolio-analysis — read
skills/project-kickoff/project-types/portfolio-analysis.md and work through all 6 questions
one at a time. The kickoff skill will save a parameter file; once it's saved, return here and
continue from "Confirm before proceeding."
If the user explicitly provides all parameters inline (e.g. "run with 15% hurdle, 2028
floor, CRREM on, account slug: greystar") and there is no prior kickoff file: accept the inline
values, skip the kickoff Q&A, but still present the "Confirm before proceeding" summary before
starting the analysis.
Parameters
| Parameter | Default | Description |
|---|
client_name | (required) | Client name — used in report header and file naming |
portfolio_id | (required) | Soapbox portfolio ID or name to query |
fund_filter | all funds | Comma-separated fund names to include, or "all" |
irr_hurdle | 15% | Minimum IRR for a measure to be recommended |
utility_escalation | 3%/yr | Annual escalation applied to energy savings |
discount_rate | 8% | Discount rate for NPV calculations |
exit_year_floor | 2028 | Assets exiting before this year are moved to floor date |
retrofit_lead_months | 18 | Measures needing > this many months to implement are deferred for near-exit assets |
target_years | [2030, 2035, 2040] | Emissions scenario target years for CRREM comparison |
value_method | inclusive | inclusive = NOI uplift capitalised at exit cap + added to terminal CF; standalone = IRR on savings only without exit value |
top_n_assets | 10 | Number of assets shown in "Top N by value creation" table |
audette_account | (ask if not known) | Audette customer account slug for switch_customer_account |
include_crrem | false | Include CRREM pathway analysis: emissions trajectory chart, stranding analysis, pathway-alignment KPIs |
include_bps | false | Include Building Performance Standards exposure analysis: BPS liability per asset, compliance cost if no action, fine avoidance as a measure benefit |
org_goal | null | Custom organizational sustainability goal (e.g. "net zero by 2040", "50% emissions reduction by 2035"). If not provided, search Portfolio Docs for ESG policy statements, fund mandates, or investor commitments before asking. When set, all report sections that reference emissions trajectory or CRREM add a line showing gap/progress vs. this goal. |
Prompt for each required parameter. Save the confirmed set to a comment in the portfolio
thread so future runs can reuse them without re-entering.
Confirm before proceeding
Display the resolved parameters in a compact table and ask: "Run with these parameters? (y to proceed, or change any value)"
Client: [client_name]
Portfolio: [portfolio name]
Funds: [all / fund list]
IRR hurdle: [X]%
Exit year floor: [YYYY]
Target years: [YYYY, YYYY, ...]
Utility escal.: [X]%/yr
Discount rate: [X]%
Value method: [inclusive / standalone]
Audette acct: [slug]
CRREM analysis: [yes / no]
BPS analysis: [yes / no]
Org goal: [goal statement / none]
Phase 1: Readiness Check & Financial Parameter Collection
Before prompting the user for any parameters, search Portfolio Docs for existing data.
Exit years, cap rates, fund assignments, hold periods, and IRR targets are often already
uploaded as spreadsheets, IC memos, or fund term sheets. Extract what you can before asking.
1A — Search Portfolio Docs first
If the user attaches a file inline in the thread (e.g. a spreadsheet with exit years), its content is already in the message — read it directly. Do NOT web_fetch the attachment URL or the Supabase signed URL.
For documents already uploaded to the portfolio (not attached inline), two tools — pick by content type:
read_portfolio_file(file_name) — for SPREADSHEETS and any file where exact
cell values matter (exit years, cap rates, asset registers, utility tables).
Returns row-aligned CSV per sheet. Semantic search chunks flatten tables and lose
row alignment — never rely on search_portfolio for per-asset numeric parameters.
search_portfolio(query) — for narrative documents (IC memos, ESG policies,
audits) where you need relevant passages, not exact rows.
Never web_fetch any URL to access portfolio docs.
Workflow: list_portfolio_files() to see what exists → read_portfolio_file for each
financial spreadsheet → search_portfolio for narrative parameters.
Call search_portfolio with specific terms to find financial parameters:
search_portfolio("exit year cap rate hold period")
search_portfolio("IRR hurdle rate fund")
search_portfolio("acquisition model underwriting")
search_portfolio("ESG sustainability net zero emissions target")
Extract from the returned chunks:
exit_year per asset
exit_cap_rate per asset or fund
fund_name assignments
- IRR hurdle rate
- Hold period assumptions
- Any sustainability/emissions goal (e.g. "net zero by 2040")
Use list_portfolio_files only to see what documents exist — it does not return file content. Use search_portfolio for all content access.
Only ask the user for parameters that couldn't be found in the docs. If you found partial data (e.g. exit years but no cap rates), confirm what you found and ask only for what's missing.
1B — Load all assets and build the UUID map
Call query_portfolio_data() to get every asset's UUID, name, and current metadata in one call. Do this before anything else — the UUID map is required for all write-back operations.
query_portfolio_data(include_metadata: true, analysis_ready_only: false)
The tool returns a pipe-delimited text block, one asset per line, in this format:
ID: <uuid> | Asset: <name> | Address: <addr> | Type: <type> | Built: <year> | GFA: <m²> | Audette: <audette_property_id> | ESPM: <espm_property_id> | Fund: <fund> | Exit: <year> @ <cap_rate>% | Lease: <lease_structure> | Metering: <metering_config> | Analysis ready: yes/no
Fields only appear when they have a value — a missing Fund: or Exit: field means that metadata has not been set yet.
Parse each line and build an internal map:
{ asset_name → { uuid, audette_property_id, espm_property_id, fund_name, exit_year, exit_cap_rate, lease_structure, metering_config, analysis_ready } }
Critical rules:
ID: is always the first field — that is the asset UUID to use for all write-back calls.
- Asset UUIDs come ONLY from the
ID: field in this response. Never extract UUIDs from file paths, URLs, Audette IDs, or any other source.
audette_property_id is a top-level field (prefixed Audette: in the output), NOT inside the metadata block.
exit_year and exit_cap_rate are in the metadata section (prefixed Exit:). If absent, these fields are unset and must be collected from docs or the user.
- Do NOT call
get_asset_record per asset — query_portfolio_data already returns everything in one call.
Partition assets into:
- Analysis-ready (
metadata.analysis_ready: true) — proceed
- Missing params — collect before proceeding
- Disposed (
metadata.status: 'disposed') — emissions inventory only
1C — Bulk-fill from register (if available)
If the user attaches a spreadsheet in the thread, its content is already in the message — read it directly from the message context. Do NOT web_fetch any URL to access an attached file.
If a spreadsheet or asset register exists in the portfolio files, read it with
read_portfolio_file(file_name) — this returns actual rows, so each asset's exit
year/cap rate stays glued to its name. Match rows to the UUID map from 1B by asset
name (fuzzy match). Then write parameters back:
update_asset_metadata(asset_id: "<uuid-from-1B>", updates: { fund_name: "<fund>", exit_year: <year>, exit_cap_rate: <rate> })
Or in bulk when the same value applies to multiple assets:
bulk_update_metadata(asset_ids: ["<uuid1>", "<uuid2>", ...], updates: { exit_year: <year> })
Always use UUIDs from the 1B map. Never guess or construct a UUID from any other source.
Auto-populate any field found in the register. Only prompt for what's still missing.
1D — Collect missing parameters asset-by-asset
For each asset still missing required fields, present a focused card:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[N/total] — [Asset Name] [fund] · [type]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✓ Audette linked: [building name]
Exit year → ? (e.g. 2030, or 'disposed')
Exit cap rate → ? (e.g. 4.5%)
Lease structure → gross / nnn / modified-gross / rubs / green-lease
Metering config → master / individual / submeter-passthrough
Jurisdiction → [auto-detected or blank]
BPS liable → [yes / no / unknown] ← only shown if include_bps: true
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Only show missing fields. skip leaves null and excludes the asset from analysis.
disposed marks the asset and includes it in emissions inventory only.
READ metadata.utility_split FIRST (canonical, persisted). If the asset already has
metadata.utility_split, USE it — do not re-derive and never default to 100% / Audette's
landlord-share. Only when it is ABSENT do you run the derivation below; then persist the result
back via update_asset_metadata(asset_id, {utility_split: {...}}) (allowlisted key — if the tool
reports a REJECTED key the write failed, surface it) so the next thread reads it instead of
re-defaulting. See the persistence contract in the utility-split-estimation skill.
Audette landlord-share is NOT optional — WRITE it to Audette AND the DB, then VERIFY. Audette's
measure model applies its OWN per-building landlord-share to compute owner savings, and it re-defaults
to ~100% on every model re-pull. So the confirmed split must be written into Audette
(default_landlord_share_electricity / default_landlord_share_natural_gas, per building) AND into
metadata.utility_split — BOTH — and read back to confirm it persisted (see utility-split-estimation
→ "Audette landlord-share settings"). Before trusting any asset's value/IRR, confirm its Audette
landlord-share equals the confirmed split; if Audette is at its default (~100%), the owner savings,
IRR, and value creation are overstated — do not use them. If Audette is unreachable, STOP and ask the
user to reconnect; never compute at 100%.
Check the leasing brochures per asset (part of the workflow, not optional). Before setting the
split, pull each asset's current leasing marketing — apartments.com, the property's own site,
Zillow rentals (via brave-search/web_fetch) — and read it for two things: (1) whether
utilities are included in rent or itemized as resident-paid ("utilities included", "resident pays
electric/gas/water", a RUBS/flat-fee line) — this is current, market-facing evidence of who bears
each fuel and directly sets/confirms the capture split; and (2) the amenity set (pool, spa,
clubhouse, fitness, common laundry, EV stalls, garage) — amenities are landlord-paid common loads
that carry their own 100%-owner capture AND surface measure opportunities (pool-heat HP, common-area
controls, EV). Cite the listing + URL; a brochure statement outranks a building-form inference.
Then determine LL/TT allocation inline (do NOT call get_ll_capture — it is broken in prod; the
hosted runtime ships no Python). Set ll_capture_pct per the end-use capture map in economics
correctness rules 1–2 above: master-metered/landlord-paid loads (central plant, elevators, common,
amenity) at their RUBS-recovery capture (~10% net owner where RUBS applies — verified per rule 1b —
or ~100% if the owner absorbs); in-unit tenant-metered ≈ 0.0–0.05; BPS fine avoidance = 1.0 always;
net owner utility savings = capture% × gross (≈$0 on low-capture/RUBS assets — never the gross).
If include_bps: false, treat fine avoidance as "not assessed" and omit it from owner NOI.
Write results to asset metadata:
update_asset_metadata(asset_id: "<asset_id>", updates: <params_object>)
Show edge-case warnings inline (NNN paradox, solar consent, RUBS recovery, BPS liability).
1E — Readiness summary
Report before proceeding:
[N_ready] assets ready · [N_missing] skipped (missing params) · [N_disposed] disposed
If N_ready = 0: stop and ask the user to provide financial parameters.
Confirm:
1E-bis — Load confirmed analysis-ready assets for the run
SELECT id, name, address, property_type, metadata
FROM assets
WHERE portfolio_id = '<portfolio_id>'
AND metadata->>'analysis_ready' = 'true'
ORDER BY name;
Count: N_ready assets ready to analyze.
Confirm:
"Found [N_ready] analysis-ready assets. [N_skipped] assets skipped — missing [fields].
Ready to run? (y to proceed, or list specific assets to exclude)"
1F — Identify Audette gaps
For each asset loaded in 1B, check the audette_property_id column (NOT metadata.audette_building_id — that field does not exist). Assets without an Audette link will have lower-quality energy data.
| Status | Count | Treatment |
|---|
audette_property_id not null | N | Full physics model from Audette MCP |
audette_property_id is null | N | Use documents (PCA/audit) + BPD MCP benchmark — label all values (est.) |
Report the gap count before proceeding. Do not stop — assets without Audette are included with
lower confidence, clearly labeled.
Phase 2: Begin the analysis (NO loading artifact)
Do not emit a loading skeleton or any hand-written HTML. Announce in chat that the analysis is
starting and stream your progress there as you work the phases. The only artifact you ever create
is the final report, and it is produced solely by fill_report(template:'portfolio-analysis', data)
in Phase 5. You never author or edit report HTML — there is no skeleton to fill in, no file to
update in place. (Authoring HTML by hand is the #1 cause of a broken, off-template deliverable and is
prohibited.)
Phase 3: Per-Asset Analysis
Audette is the mandatory primary data source. Call it for EVERY linked asset. Do not skip Audette and proceed on docs alone — if Audette is skipped, the analysis is incomplete and must say so.
Do NOT process assets one-at-a-time — but do NOT fan out more than 6 tool calls in a single
turn either. Issue the same step's tool call for a group of assets in ONE message (a "burst"),
read the whole set of results, then move on. The dependency is step → step, not asset → asset
(e.g. get_building_model_details needs the find_buildings result, but asset B's
find_buildings does not need asset A's), so grouping is safe.
⚠️ Hard cap: ≤ 6 tool calls per turn. The managed-agents runtime drains a turn's parallel
tool calls through a one-at-a-time requires_action handshake; bursts larger than ~6 churn badly
and can stall the run before it ever renders. So a burst of 6 is the sweet spot: it kills the
~200-serial-round-trip problem of one-call-per-turn WITHOUT tripping the runtime's churn. If a
batch has more than 6 assets, split the step into back-to-back 6-call bursts.
After each burst, stream a progress line as the set completes:
✓ Batch 1 economics: 6/6 assets — 24 measures above hurdle (not one line per asset).
Progress lines are mandatory, not optional. After each burst (find_buildings, decarb plans,
compute_plan_economics), emit a short text line — e.g. Pulled decarb plans 6/6 (12/39 total)….
Never go more than one burst without a visible line of text; a silent tool storm looks like a hang.
3·0 — BATCHED EXECUTION (required for portfolios > ~8 assets — this is how the run converges to a render)
A large portfolio (e.g. 39 assets) does NOT fit in one session's working context: holding every
asset's raw Audette plan + CRREM curve + legislation at once bloats the model's context until it
can no longer emit fill_report — the run grinds for an hour and then summarizes instead of
rendering. Process assets in BATCHES and persist a compact result per asset, so the final render
turn reads small saved summaries, not the raw data.
Protocol:
- Batch size ≤ 6 assets. Split the analysis-ready set into batches of at most 6, so each
fan-out step is a single ≤6-call burst (matches the runtime's clean drain size).
- Run 3A–3D as ≤6-CALL BURSTS over the batch, then PERSIST a compact
pa_result per
asset and DROP the raw data. Do not walk the batch asset-by-asset. Instead sweep the batch
step-by-step, issuing that step's call for the (≤6) assets in ONE message:
- Step A (burst):
find_buildings for the ≤6 assets in one message → read all → then
get_building_model_details for the resolved building models (again ≤6 per turn).
- Step B (burst):
get_reported_carbon_reduction_plan (+ any doc search_portfolio) for
the ≤6 in one message.
- Step C (reason, no tool): for each asset determine the per-fuel LL capture map inline
from correctness rules 1–2 (RUBS/VNM/master-meter) — do NOT call
get_ll_capture (broken
in prod); assemble each asset's owner-share flows.
- Step D (ASSEMBLE flows — do NOT compute economics here): for each asset build owner-share
flows PER MEASURE (year 2026…exit_year, each with incremental_capex,
gross_elec_savings/gross_gas_savings/gross_solar_savings + elec_capture/gas_capture/
solar_capture, ancillary_revenue, bps_fine_avoidance) and store on each measures[].flows.
These per-measure flows are the SINGLE source of truth: Phase 4 sums them for the asset-level
economics AND screens them per measure for the A/B/C/D emissions scenarios — so the headline
value and the trajectory can never diverge. Do NOT also build an asset-level flows aggregate by
hand. Additionally capture, for the trajectory: scope2_fraction (the electricity share of the
asset's baseline emissions) and grid_ef_annual (the annual electricity emission-factor series
from crrem get_emission_factors for the asset's country) — these drive the deterministic BAU
grid-decay curve.
Do NOT call compute_plan_economics per asset, and NEVER compute or aggregate IRR / value /
capitalization yourself (no bash, no Python, no spreadsheet). The deterministic engine runs
exactly ONCE for the whole portfolio in Phase 4 via —
that single call, and its stamp, is the ONLY valid source of every economic number.
Then write each asset's result via and
do NOT carry that asset's raw Audette/CRREM/legislation payloads forward into the next batch.
The is the ONLY thing that must survive to Phase 4. Compact shape:
(The retrofit register already persists the measures durably; is the compact
analysis rollup — its feed the Phase-4 engine call.)
This bounds per-session context so the run reliably reaches fill_report. Single-asset engagements
(decarb-plan) don't need this; it's specific to portfolio scale.
3A — Pull all sources, reconcile, write back to Audette
For every asset with an audette_property_id: call Audette FIRST (Steps 1–3), then search docs (Step 4), then reconcile (Step 5), then write back (Step 6). The doc search in Step 4 is secondary and supplementary — never a replacement for Audette.
If you have already searched docs during Phase 1 (financial params), that does NOT count as Step 4. Step 4 must be a targeted per-asset search for energy measures, equipment data, and utility consumption — separate from the financial param search.
If the Audette MCP is available: complete Steps 1–6 for every linked asset. Completing the analysis on docs alone when Audette is available is not acceptable.
If the Audette MCP is unavailable at runtime: read uploaded docs only, build the plan from those, note the gap prominently, and skip the write-back steps. Do not silently omit this notice.
Step 1 — Switch to the correct Audette account
switch_customer_account("<audette_account_slug>")
Use list_customer_accounts() if unsure which slug applies — pick the account whose name matches the client.
Step 2 — Build the property map
list_properties()
Returns { property_uid, property_name } pairs for the active account. Build:
{ property_uid → property_name }
Each asset's audette_property_id is a property UID in this map. Properties and
building models are different objects: analysis tools take a building_model_uid,
and one property can contain multiple building models.
NEVER call bare list_buildings() — large accounts contain thousands of buildings
and the result will not fit. Use audette__find_buildings instead (Step 3).
Step 3 — Resolve each asset's property to its building models
For each Soapbox asset where audette_property_id is not null:
property_name = property_map[asset.audette_property_id]
audette__find_buildings(name: property_name)
→ returns all building models whose property_name or building_name matches,
each with building_model_uid, gross_floor_area, fund_name, modelling_status
Then for every returned building model:
get_building_model_details(building_model_uid)
Multi-building properties — aggregate to the asset level:
- CapEx, annual savings, and emissions reductions: sum across the property's buildings
- EUI and carbon intensity: floor-area-weighted average (weight by
gross_floor_area)
- Measures: union the lists; prefix each with the building name when a property has >1 building (e.g. "Bldg A — LED retrofit")
- CRREM status: use the weighted carbon intensity for the property-level comparison
- Never report just one building of a multi-building property as if it were the whole asset
If audette__find_buildings returns no match for the property name, note the gap and
fall back to the BPD benchmark path — do not guess a different building.
Pull from the response:
current_eui_kwh_m2 — site EUI from Audette calibrated model
carbon_intensity_kg_co2_m2 — Scope 1+2, location-based
crrem_pathway_target_2030 — CRREM 1.5°C target for asset type (only if include_crrem: true)
crrem_misalignment_year (only if include_crrem: true)
equipment_schedule — age and condition of major systems
recommended_measures[] — Audette decarb plan, each with:
measure_type, capex, install_cost, annual_savings_kwh, annual_savings_$
Also pull the capital plan:
list_building_plans(building_model_uid) # once per building model in the property
→ get_carbon_reduction_plan_by_id(plan_id) # for the active plan
This gives the full measure list with costs, savings, and implementation schedule.
Step 4 — Search uploaded documents as secondary source
After loading Audette data, call search_portfolio with the asset name to pull content from energy assessments. Do not navigate to URLs or use web_fetch — search_portfolio is the only way to access document content in a portfolio thread.
search_portfolio("energy audit [asset name]")
search_portfolio("engineering study [asset name]")
search_portfolio("capital plan [asset name]")
search_portfolio("measures recommendations [asset name]")
search_portfolio("utility consumption EUI [asset name]")
From the returned chunks, extract:
- Measures recommended (type, description, estimated cost, estimated savings)
- Measures already completed (if noted as "installed", "completed", "replaced")
- Equipment condition observations that differ from Audette's equipment schedule
- Utility consumption data if more recent than Audette's baseline
Step 5 — Reconcile Audette vs. uploaded docs
Build a unified measure list per asset, reconciling all sources against the verifier's
rubrics. The first time you reconcile a given data type in a run, pull its checklist —
verifier__get_verification_checklist(data_type) for energy, equipment, physical,
financial as relevant — and follow it (e.g. energy: sanity-check units before comparing
values, cross-check against ESPM/BPD peer bands; financial: every figure originates from a
deterministic engine or cited doc). The checklists are the same methodology decarb-plan uses;
they replace the ad-hoc confidence ladder this skill used to carry.
Source certainty: Audette and uploaded field documents (energy audits, PCAs,
engineering studies, drawings, condition assessments) carry equal weight. Neither is
authoritative over the other — a field-verified cost estimate from an engineering study is
as reliable as an Audette model figure. Only
BPD MCP benchmark estimates (no Audette, no uploaded doc) are lower certainty and must be labeled (est.).
-
De-duplicate: if a measure appears in both Audette and a doc (e.g. LED upgrade),
keep one entry. Use whichever source has the more detailed or recent cost/savings data.
If figures differ by > 25%, this is a material conflict: do NOT silently pick one.
Auto-apply the reconciliation hierarchy (measured/ESPM actuals > audit-reported 12-mo >
Audette modeled > BPD estimate) to choose the working value, and record the conflict so
it is durable and surfaces in the report's Data Quality section:
verifier__record_finding(
asset_id: "<Soapbox asset UUID>", # never the Audette uid
claim: "<field> for <asset>: Audette says X, <doc> says Y (>25% apart)",
verdict: "conflict",
severity: "high", # material to CapEx/savings/IRR → high; cosmetic → low
kind: "data-quality",
evidence: ["Audette model: X <unit>", "<doc name>: Y <unit>"],
sources: ["Audette", "<doc name>"]
)
Set severity by materiality to CapEx, savings, or the IRR screen. At screening scale the
hierarchy suggestion is applied automatically (no per-asset user gate) — the finding is the
audit trail, and high-severity findings are the ones surfaced for optional review at Phase 4.
-
Mark completed measures: if any source shows a measure was already installed (e.g.
"LED retrofit completed 2023"), remove it from forward-looking CapEx. Do not
double-count. Doc evidence of completion overrides Audette if Audette still lists it
as recommended.
-
Confidence levels (the verifier's two-source rule):
High — two or more independent sources agree (Audette + doc, or two docs)
Medium — single source, either Audette or a field doc (provisional)
Low — BPD MCP benchmark only (no Audette, no uploaded doc); these assets are handled as
needs-data in Step 3C and excluded from the verified roster and headline aggregates
-
Feasibility check per measure:
- Technically feasible given building vintage and HVAC config?
- Can it be permitted and built within the hold period?
(Heat pumps / envelope: 18+ months lead; LED/controls: 3–6 months)
- Does LL/TT allocation flow savings to landlord?
(NNN tenant-pays = near-zero NOI capture on in-unit measures)
- Requires tenant cooperation? (solar on leased roof, sub-metering, RUBS rollout)
-
IRR screen last — apply IRR hurdle only after measures are compiled, de-duped,
feasibility-checked, and LL/TT allocated. Exclude feasibility failures before IRR screen.
Step 6 — Write reconciled data back to Audette
Audette is the write-back destination (system of record for future runs). After
reconciliation, update it with anything the docs revealed that Audette doesn't yet reflect:
-
Mark completed measures — for any measure docs show as already installed, use
update_custom_plan_measures to remove it from the active decarb plan.
-
Submit utility data — if uploaded utility bills are more recent than Audette's
baseline, submit via add_building_utility_data to recalibrate the carbon reduction plan.
-
Equipment updates — if docs show equipment replacement not in Audette's schedule,
call edit_building_attributes to update it.
-
Flag calibration gaps — if the Audette model is based on design specs rather than
measured data, note: "Audette model not yet calibrated — submit utility bills to recalibrate."
The goal: Audette should be more accurate at the end of the run than at the start.
For assets with no Audette link and no uploaded docs:
- Call
get_statistics(analyze_by: "site_eui", filters: {building_type: ["<asset_type>"], climate_zone: ["<zone>"]}) on BPD MCP to get the median EUI for the asset's building type + climate zone. Use the 50th-percentile value as the baseline EUI — label every derived value (est.).
- Circular benchmarking rule: Never use a BPD-derived benchmark EUI as the subject EUI in a subsequent BPD
get_eui_percentile call. Skip the percentile comparison for that asset.
3B — Establish hold window (NO base DCF model)
There is no run_dcf base model — value creation is an incremental bridge, not a
levered whole-asset DCF, so a going-in NOI model is not required (and run_dcf is broken in prod
anyway — see ground rule 2). compute_plan_economics computes IRR and value creation from the NOI
delta (the incremental owner-share flows: capex out, owner utility savings + ancillary +
avoided fine in, terminal uplift = annual NOI delta ÷ exit cap) — it does NOT take the going-in
NOI level. Never ask the user for going-in NOI, and never block or defer a run because it is
missing. For each asset just fix the hold window:
hold_exit_year = max(asset.exit_year, exit_year_floor)
install→exit years = each measure's install_year … hold_exit_year
If hold_exit_year - current_year < 1 (already past exit): mark disposed, skip financial analysis.
3C — Compute per-asset economics via compute_plan_economics + persist to the retrofit register
The economics run through compute_plan_economics (Steps 1–6 below); the retrofit
(building-science) agent frames the candidates, supplies feasibility/staging doctrine, and is
the system of record for the resulting measures (Step 0 and Step 7). Persisting to the
register means a later decarb-plan engagement on any of these assets inherits this work.
Step 0 — Candidate framing & feasibility doctrine (retrofit agent):
- Once per asset, call
retrofit__propose_candidates(asset_attributes: {archetype, jurisdiction, equipment, ...}) with the reconciled asset attributes — it returns the source checklist and
origination prompts that make sure you haven't missed a candidate family.
- Pull the building-science doctrine that governs feasibility and phasing:
retrofit__get_retrofit_playbook('staging') for sequencing against capital events/end-of-life,
and the relevant measure-family playbook (hvac, envelope, dhw, controls-rcx,
solar-storage, electrification-staging) for any measure whose feasibility you're scoring.
This doctrine — combustion safety after air-sealing, A2L refrigerants, envelope-before-HVAC
sizing — informs the feasibility check in Step 5 below and the feasibility.score in Step 7.
For each reconciled measure per asset, build the owner-share cash flows for
compute_plan_economics — do NOT use simple payback math, and do NOT call get_ll_capture
(broken in prod).
Step 1 — LL/TT allocation (inline capture map — per correctness rules 1–2, no tool):
Set ll_capture_pct per measure from who BEARS the cost of that end-use (metering AND
RUBS-recovery), not a blended building number and not "who pays the meter":
| End-use the measure touches | ll_capture_pct |
|---|
| Master-metered / landlord-paid (central heating/DHW plant, elevators, common/garage ventilation, common lighting, amenity) — jurisdiction ALLOWS RUBS (assume owner rebills up to ~90%) | ≈0.10 (net owner) — the default for master-metered in a RUBS jurisdiction |
| Same loads, but owner absorbs the utility (documented gross lease / RUBS not permitted) | 1.0 (100% owner) |
| In-unit tenant-metered loads (in-unit HVAC/appliances) | tenant-metered owner share (0.0–0.05) |
| Solar under Virtual Net Metering (VNM) allowed | 0.80 (owner) |
| BPS fine avoidance | 1.0 (100% owner) — always, regardless of lease/metering |
Never read "master-metered" as "100% owner" — that is the common error; master-metered just
means the owner pays the meter, and under RUBS ~90% is rebilled to tenants. Then compute net
owner utility savings = measure.annual_savings_$ × ll_capture_pct, and for a fuel switch
net any owner-side load increase (do not credit the owner the whole gas cut while assigning the new
electricity to tenants — rule 1). Never inherit Audette's 15% account default. Tenant-side savings
are tracked separately and do NOT enter the value bridge.
Step 2 — Retrofit lead time feasibility:
- Major capital (HVAC, electrification, envelope, solar): requires 18+ months — defer if hold_period < 1.5 yrs
- Compliance-required measures: never defer regardless of hold period
- Controls/LED/commissioning: 3–6 months — feasible for any hold period
Resequencing Audette's install years — bounded, and disclosed. Audette often schedules
measures past the exit year, which understates in-hold value. You MAY pull a measure's install
year earlier than Audette's schedule only if its lead time fits before the exit
(controls/LED/commissioning: any hold; HVAC/electrification/envelope/solar: only if ≥18 months
remain to exit). Never front-load a long-lead measure into a window it cannot physically be
permitted and built in just to book its exit-year uplift — that fabricates value. When you do
resequence, say so in the report's methodology ("install years accelerated within feasible lead times to reflect a value-maximizing owner"), and keep the max-decarb/full-roadmap flow on
Audette's original schedule so the two views are honestly distinguishable.
Step 3 — Value-inclusive IRR + value bridge (call compute_plan_economics):
Build the per-year owner-share flow schedule for the measure (or, for the asset roll-up, all
recommended measures combined) and call the one working engine:
compute_plan_economics(
flows: [ for each year install_year … hold_exit_year:
{ year,
incremental_capex: <incremental capex over like-for-like, in the install year(s); positive>,
owner_utility_savings: <measure.annual_savings_$ × ll_capture_pct, net of owner-side load increase,
ESCALATED to year Y: × (1 + utility_escalation)^(Y − install_year)>,
ancillary_revenue: <owner-share solar/EV/DR this year — risk-adjusted, NOT a perpetuity>,
incentives: <IRA credit received this year>,
bps_fine_avoidance: <annual fine avoided, ESCALATED with the standard's fine schedule — only if
non-compliant on the GOVERNING pathway> } ],
exit_cap_rate: asset.exit_cap_rate,
exit_year: hold_exit_year,
discount_rate: <portfolio discount rate, default 0.08>
)
→ returns irr_incremental, the full cashflow schedule, and the value-creation waterfall
(capitalized owner savings & ancillary ÷ exit cap, PV of fine avoidance, net_value_creation,
terminal exit-value delta)
Feed only auditable inputs — never pre-compute IRR, capitalization, or PV yourself. Apply
utility_escalation (the run parameter, default 3%/yr) to owner_utility_savings each year —
savings grow as utility rates rise, so a flat savings line understates later-year NOI and the
capitalized exit value. The terminal exit uplift capitalizes the STABILIZED (exit-year, escalated)
owner-NOI improvement ÷ exit cap.
For batch screening across assets: loop compute_plan_economics per asset (there is no
screen_measure_portfolio — it is broken). One call per asset (all its recommended-measure flows)
gives the asset's value creation + IRR; per-measure calls give per-measure IRR for the screen.
Step 4 — Use the engine's outputs directly:
Use irr_incremental and the waterfall from compute_plan_economics — do not recompute with a
local dcf_engine.py script (not shipped) and do not call run_dcf/run_intervention_irr
(broken). The value bridge is unlevered and incremental:
- Outflow at
install_year = incremental capex (over like-for-like)
- Annual inflow = net owner-NOI improvement (owner utility savings + ancillary + avoided fine)
- Terminal uplift at
exit_year = annual NOI improvement ÷ exit cap ← the reported value creation
Step 5 — IRR screen (record BOTH IRRs from compute_plan_economics):
Capture irr_excl_exit AND irr_incremental for every measure — they drive the two screens and
the A/B/C trajectory (Phase 4E). The roster's "recommended" status uses irr_incremental
(value-inclusive, scenario B — the standard recommendation bar); irr_excl_exit additionally tags
which measures also clear operationally (scenario A).
| Result | Action |
|---|
irr_incremental ≥ irr_hurdle | Include in recommended measures (also tag pays_operationally: true if irr_excl_exit ≥ hurdle) |
irr_incremental < hurdle | Exclude from recommendations; include in "below hurdle" table |
| Compliance-required measure | Include regardless of IRR; flag as mandatory |
This IRR result is an input to the final status, not the status itself. Step 7 composes it
with the retrofit__screen_measures label to produce the authoritative reported status — a
measure that clears the IRR hurdle here can still be screened-out/needs-data if it fails the
building-science feasibility or provenance screen. Do not assign a final status at this step.
Step 6 — IRA incentive check:
For measures with IRA eligibility:
- Solar/geothermal/battery: IRA §48E — 30% base ITC (REIT direct pay eligible)
- Envelope/HVAC/lighting: IRA §179D — up to $5.65/SF (only if renovation qualifies)
- Multifamily energy efficiency: §45L — $500–$5,000/unit
- Low-income / energy community bonus: check census tract (+10pp on §48E)
Reduce net_capex = install_cost × (1 - ira_credit_rate) for IRA-eligible measures.
Re-run IRR on net_capex — some measures below hurdle on gross may pass on net.
Label net capex separately from gross in all tables.
Step 7 — Evaluate, persist, and screen through the retrofit register:
Now that compute_plan_economics has produced the economics for the measure, record it in the
retrofit register through the provenance gate, then screen it. This is what makes the measure
durable and disciplined — the gate rejects any number without engine or source provenance.
-
retrofit__evaluate_measure — feed the engine outputs in as engine-provenanced fields.
feasibility.score is an integer 1–5 informed by the Step 0 playbook doctrine; every econ
field carries engine: "compute_plan_economics" or a cited source:
retrofit__evaluate_measure(
asset_id: "<Soapbox asset UUID>",
measure: {
measure_family: "hvac", name: "...", candidate_source: "audette|pca|audit|originated",
cost: { value: <net or gross incremental capex>, unit: "USD", engine: "compute_plan_economics" },
owner_savings_annual: { value: <annual_savings_$ × ll_capture_pct, net of load increase>, unit: "USD/yr", engine: "compute_plan_economics" },
noi_delta_annual: { value: <annual owner-NOI improvement>, unit: "USD/yr", engine: "compute_plan_economics" },
cap_rate: { value: <exit_cap_rate>, unit: "ratio", source: "<verbatim source of the cap rate>" },
incentives: [{ value: <ira_credit_$>, unit: "USD", program: "§48E|§179D|§45L", eligibility_basis: "...", source: "<statute/rule>" }],
feasibility: { score: <1-5>, site_conditions: "...", disruption: "none|light|in-unit|vacancy-required", contractor_reality: "...", staging: "<from staging playbook>", sources: ["<audit/PCA/Audette/playbook>"] },
future_proofing: { rationale: "...", citations: ["..."] }
}
)
Populate future_proofing.citations ONLY when there is a genuine reason to keep a measure
despite failing economics — a pending code/BPS requirement, a forced end-of-life replacement,
or a documented future-proofing rationale. Leave it empty otherwise. screen_measures
labels any value-failing measure with non-empty citations defensive instead of screened-out;
filling citations reflexively on every measure inflates the roster and lets weak measures escape
the value screen. Empty citations let poor economics screen out honestly.
The tool computes exit_value_delta = noi_delta_annual ÷ cap_rate server-side as a screening
proxy — do not use it in report value-creation tables (those stay compute_plan_economics's
waterfall / terminal exit-value output). If the gate rejects a field, supply the real provenance —
never fabricate an engine/source string to get past it.
-
retrofit__screen_measures(asset_id) — labels each measure
recommended / defensive / screened-out / needs-data (feasibility ≥ 3, simple payback ≤ 15y,
NOI-positive). This persists the label onto the register.
Note: screen_measures/evaluate_measure save sequentially per measure. Across 30–40 assets
this is slower than a batch write but does not block; it is a known v1.1 batching follow-up.
3D — Asset-level output
For each asset, compile:
{
"asset_id": "...",
"asset_name": "...",
"fund": "GEdR",
"exit_year": 2030,
"exit_cap_rate": 0.045,
"hold_period_years": 4,
"gav": null,
"audette_eui": 85.2,
"carbon_intensity_kg": 42,
"crrem_status": "above_pathway",
"crrem_misalignment_year": 2029,
"ll_capture_pct_avg": 0.72,
"lease_structure": "modified-gross",
"metering_config"
...
...
Stream one line per asset as it completes.
3E — Build supporting data arrays for the XLSX companion
After all assets complete, flatten the per-asset outputs into two arrays the XLSX template requires for analyst verification:
all_measures — every measure across all assets (recommended + below hurdle + deferred), one row per measure:
[
{
"asset_name": "Observer Park",
"fund": "GGIF",
"measure": "LED lighting retrofit",
"category": "LED lighting",
"status": "recommended",
"install_year": 2026,
"capex_gross": 280000,
"ira_credit": "§179D 30%",
"capex_net": 196000,
"annual_savings": 31000,
"ll_capture_pct": 1.0,
"annual_noi_uplift": 31000,
"exit_value_uplift": 688000,
"irr": 0.21,
asset_source_data — raw inputs per asset (Audette data + DCF parameters) for analyst verification:
[
{
"asset_name": "Observer Park",
"fund": "GGIF",
"property_type": "Multifamily",
"gfa_m2": 12400,
"year_built": 1998,
"exit_year": 2031,
"exit_cap_rate": 0.045,
"gav": null,
"hold_period_years": 5,
"eui_kwh_m2": 85.2,
"ghgi_kg_m2": 42.0,
"baseline_emissions_tco2": 521,
"lease_structure": "modified-gross",
"metering_config": "master-metered"
Phase 4: Portfolio Aggregation
After all assets complete: aggregate from the persisted pa_results (Phase 3·0), NOT by
re-pulling Audette/CRREM. Read every asset's metadata.pa_result in one pass, then compute
everything below from those compact rollups — this is what keeps the render turn's context small
enough to emit fill_report. Re-pulling raw plans here re-bloats context and defeats batching.
4A — Portfolio KPIs
| KPI | Formula |
|---|
| Total CapEx (gross) | Σ all recommended measures, all assets |
| Total CapEx (net of IRA) | Σ net_capex |
| Total value creation | Σ value_creation (at exit cap) |
| Total NOI uplift (annual) | Σ annual_noi_uplift |
| Total emissions reduction | Σ emissions_reduction_t_co2 |
| Assets above hurdle | Count where any recommended measure exists |
| Assets fully pathway-aligned (2035) | Count where carbon_intensity ≤ CRREM 2035 target after measures — only if include_crrem: true |
| Compliance exposure (no action) | Σ compliance_cost_if_no_action — only if include_bps: true |
Report all values with 2 significant figures: $14M, $2.1M, 68 tCO₂e, 22 assets.
4B — Fund-level breakdown
Group by fund_name. For each fund:
| Fund | Assets | CapEx (net) | Value Creation | Emissions Reduction | Avg IRR |
|---|
| GEdR | 12 | $5.2M | $8.4M | 320 tCO₂ | 18% |
| GGIF | 9 | $3.1M | $4.7M | 190 tCO₂ | 22% |
4C — Top-N by value creation
Sort by total_value_creation DESC. Show top top_n_assets (default 10).
| Rank | Asset | Fund | Exit | CapEx Net | Value Created | Lead Measure |
|---|
| 1 | Observer Park | GGIF | 2031 | $1.8M | $4.2M | Heat pump retrofit |
4D — Measure category aggregate
Roll up recommended measures across all assets by category:
| Category | Assets | Total CapEx Net | Value Creation | tCO₂ Reduced |
|---|
| LED lighting | 28 | $2.1M | $3.8M | 120 tCO₂ |
| Smart HVAC/controls | 15 | $4.2M | $6.1M | 240 tCO₂ |
| Heat pump / electrification | 8 | $12M | $9.4M | 580 tCO₂ |
| Solar PV | 11 | $3.4M | $2.8M | 95 tCO₂ |
| EV charging | 19 | $1.9M | $1.1M | — |
| Envelope | 6 | $5.8M | $4.2M | 180 tCO₂ |
4E — Emissions trajectory: three-scenario time series (2025–2050) (only if include_crrem: true)
Skip this section entirely if include_crrem: false. Do not produce the chart, the scenario JSON, or the stranding flag.
Build a year-by-year portfolio emissions model for three scenarios and the CRREM 1.5°C pathway. This is the centrepiece of the report — it shows where the portfolio is going and what each investment strategy delivers.
THE CENTRAL QUESTION this analysis answers: how far down the carbon-reduction curve can the
portfolio get under three progressively less-conservative capital screens? Model BAU as the
reference, then three deployment scenarios — each is the SAME measure set filtered by a different
economic bar. Each scenario is cumulative over the one before it in ambition.
| Scenario | Definition |
|---|
| Business as Usual (BAU) | No decarb capital deployed. Only passive grid decarbonization applies to Scope 2 (~3.5%/yr US multifamily, varies by grid region). Reference line. |
| A — IRR ≥ hurdle, EXCLUDING exit residual | Deploy every measure whose irr_excl_exit ≥ irr_hurdle (operating cashflows only: owner utility savings + ancillary + annual avoided fine − capex + incentives; NO capitalized exit-value uplift). The "pays for itself operationally" set. Shallowest curve. |
| B — IRR ≥ hurdle, INCLUDING exit residual | Deploy every measure whose irr_incremental ≥ irr_hurdle (value-inclusive: operating + the capitalized exit-value uplift / avoided-fine capitalization folded into the exit year). A superset of A — the exit residual pulls more measures over the bar. Deeper curve. |
| C — B + max solar (BTM + VNM) | Scenario B plus on-site solar sized to the maximum feasible: behind-the-meter self-consumption (100% owner offset) and virtual-net-metered export (80% owner per the capture rule). Add every asset's max-viable solar array regardless of whether a smaller array would have cleared the hurdle. |
| D — Next Owner's Perspective | The SAME irr_incremental ≥ hurdle screen as B but computed at exit_year = 2040 (a rational acquirer's hold horizon), NOT a hold recommendation for the current owner. A 2031 buyer underwrites a longer hold + the 2030/2040 BPS & CRREM obligations + electrification risk, so more measures clear the hurdle → the deepest curve. Narrate strictly as the buyer's underwriting lens / the trajectory the asset is actually on — never as "you should hold to 2040." |
irr_excl_exit and irr_incremental both come from compute_plan_economics (per measure or the
asset roll-up; D re-runs it with exit_year=2040). A ⊆ B ⊆ C ⊆ D by construction. Compliance-required
measures are in all four.
Exit-price protection (the seller's payoff on a short hold — populate exit_price_protection).
Scenario D matters to a 2031 seller because the buyer chips the bid for what D would fix. Quantify
the avoided chip, grounded + cited: (a) BPS fine exposure — the cap-rate expansion or $
haircut a buyer applies for ACTUAL jurisdiction BPS fines they would inherit (state the bps or %
assumption, tied to the real-fine basis). This is $0 unless the asset faces actual fines — do NOT
manufacture a chip from CRREM stranding or a generic "brown discount"; stranding is never monetized;
(b) electrification deferred-retrofit reserve — the future gas→electric capex + policy risk a buyer
deducts from their bid. Decarbing toward D removes both, so a short-hold owner captures the value at
the closing table, not the meter. This is the report's punchline — surface it in the executive summary.
CRREM stranding is timing-only, never monetized. Report CRREM stranding as the stranding year /
pathway alignment ONLY — never as a dollar, PV, capitalized value, cap-rate expansion, or brown-discount.
The ONLY capitalized/monetary downside in the report is actual BPS fine avoidance (real jurisdiction
fines from the fines engine / BPS data). If an asset faces no actual fines, there is no capitalized
downside — do not invent one from stranding.
CRREM overlay — server-side blended, never hand-built (economics correctness rule 4): make ONE
call to crrem MCP get_portfolio_pathway(country, propertyType, assets:[{region, gfa_m2}]) — pass
one entry per asset (its crrem_meta region + gfa_m2); the tool fetches each region's pathway and
returns a single GFA-weighted blended_pathway ([{year, target}]). Pass that array VERBATIM to
compute_portfolio_economics as crrem_target_annual. Do NOT fetch per-asset get_pathway
allYears curves and hand them in one-by-one — at portfolio scale those payloads are large and
stall the run; get_portfolio_pathway does the fetch+blend server-side and returns a tiny curve. Do
NOT reuse an Audette crrem_pathway_target_* field, interpolate, sample at target years, or eyeball
the annual points (that produced fabricated one-year cliffs). If crrem is unreachable, say so and
omit the CRREM overlay — never fabricate it. (get_pathway per asset is still fine for single-asset
CRREM detail; for the PORTFOLIO curve use get_portfolio_pathway.)
The scenario curves + CRREM overlay are computed by the engine, not by hand. The A/B/C/D
definitions above are what the engine implements; you do NOT build the year-by-year series. Supply
compute_portfolio_economics the top-level crrem_target_annual (the blended curve above) plus each
asset's measures[] (each with flows, install_year, annual_tco2e_reduction, is_solar,
compliance_required), baseline_intensity_2025, gfa_m2, scope2_fraction, grid_ef_annual — and it returns trajectory.emissions_trajectory
({year, bau, scenario_a..d, crrem_target} for 2025–2050) and trajectory.crrem_trajectory. It
screens each measure from its own flows (A=irr_excl_exit≥hurdle, B=+irr_incremental≥hurdle,
C=+all solar, D=+screen re-run at d_exit_year=2040) as cumulative unions (A⊆B⊆C⊆D by
construction), decays BAU's Scope-2 share by the supplied grid factors, and asserts the curves are
nested + non-increasing. Copy both arrays into the report data object verbatim; never recompute or
smooth them. (v1 treats each measure's annual tCO₂e as constant against a decaying BAU — the engine
discloses this in its methodology_note; fold that note into the report methodology.)
Sanity check (rule 6): the engine already asserts each scenario curve is non-increasing and
A ≥ B ≥ C ≥ D residual intensity (it throws if out of order). If the call returns a trajectory.error
instead of the arrays, fix the inputs (usually a missing per-asset gfa_m2 / crrem_annual /
grid_ef_annual / measures) and re-call — never hand-substitute a curve to work around it.
Output: the engine returns trajectory.emissions_trajectory — a JSON array of
{ year, bau, scenario_a, scenario_b, scenario_c, scenario_d, crrem_target } (kgCO₂e/m²) for
2025–2050 (the template reads exactly these keys). Copy it verbatim into the report data object's
emissions_trajectory. It always spans through 2040+; the target-year table reports 2030/2035/2040.
Render as an inline SVG line chart (the template draws it from the keys above):
- X axis 2025–2050 (curve visible through ≥2040); Y axis kgCO₂e/m² (portfolio GFA-weighted)
- Lines: BAU (grey dashed), CRREM target (red dashed), A (blue), B (green), C (purple), D — Next Owner (amber)
- Shaded stranding-risk zone between BAU and CRREM; annotate the year each scenario crosses under CRREM.
Report the answer to the central question explicitly — for A, B, C, and D: the achieved portfolio
GHGI-reduction % at 2030 / 2035 / 2040, the net CapEx deployed, and whether/when it clears the CRREM
1.5°C line. Frame A/B/C as the current owner's 2031-exit screens and D as the next-owner lens + the
exit-price-protection story (exit_price_protection). That comparison IS the headline of this report.
Use inline SVG only — no external charting libraries. The chart should be self-contained and print-ready.
Circular benchmarking rule: Only include assets with actual EUI data (Audette or ESPM) in the pathway. Assets with BPD-estimated EUI are listed separately as "EUI unverified — excluded from trajectory."
Flag: "Under BAU, [N] assets cross the CRREM stranding threshold before [target_years[0]]. Under the 15% IRR pathway, [M] strand. Under maximum decarb, [P] strand."
4F — Verification pass (batch render gate)
decarb-plan fails the whole render closed on any open high-severity finding. At portfolio
scale that would let one bad asset block the entire report, which is wrong — so the gate is
per-asset and called out, not fail-closed:
- For every analyzed asset, call
verifier__verification_status(asset_id) → {pass, open_high, open_total}. Record each asset's pass and open_high alongside its output.
- Also call
verifier__verification_status() with no asset_id for any portfolio-level
findings recorded during the run.
- Assets with
pass: false are flagged inline in the Asset-by-Asset table (a ⚠ marker) and
their contribution to the headline KPIs is disclosed in client-facing terms — e.g.
"3 assets rely on data still being confirmed; $2.1M of the $14M headline CapEx derives from
those assets." Never let the totals silently absorb unconfirmed data. (Internal note: this
is driven by verifier__verification_status, but that machinery must NOT appear in the
rendered report — no "verifier", "finding", "high-severity", "Gate".)
- The Data Quality section (Phase 5) summarizes confidence in client-facing terms
(
data_quality.summary + items[] dots) — how many assets use measured vs. estimated data