@uhero/feasim 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,7 +47,7 @@ A pathway is a set of rules a project can be built under. Each pathway sets what
47
47
  | --- | --- | --- |
48
48
  | `"bill6"` | Standard (current zoning) | The zone's FAR (as set by Ordinance 26-7), lot coverage and height under the Land Use Ordinance, with the Article 6 parking minimums |
49
49
  | `"standard"` | Before Ord. 26-7 | The FAR and lot coverage rules as they stood before Ordinance 26-7, for comparison |
50
- | `"bill7"` | Bill 7 | An affordable housing pathway in the A, AMX, BMX and Waikiki (Apart, ApartMix) districts, with FAR 4.0 and a 60 ft height cap, construction grants, waived impact fees, and no required parking. Timing, excavation and stepbacks follow the zone |
50
+ | `"bill7"` | Bill 7 | An affordable housing pathway in the A, AMX, BMX and Waikiki (Apart, ApartMix) districts, with FAR 4.0 and a 60 ft height cap, construction grants, waived impact fees, and no required parking. Excavation and stepbacks follow the zone |
51
51
  | `"tod"` | Transit-oriented development | PD-T and IPD-T permits near rail stations (ROH § 21-9.100): FAR and height bonuses by tier, station area plan limits, no required parking |
52
52
  | `"h201"` | HRS § 201H | Exemptions for affordable housing projects, on current (Ord. 26-7) zoning FAR and lot coverage. The defaults make 51% of units affordable at 80% of area median income (AMI), with market units at 120% AMI; set `affordability` and `affordable_ami` to change that. |
53
53
 
@@ -91,6 +91,8 @@ calcParcel("bill6", parcel, {
91
91
 
92
92
  **Note:** `height_limit` and `max_far` override the pathway's envelope. Bill 7 still caps height at 60 ft. See the `SliderInputs` type for the full list, with units.
93
93
 
94
+ **Note:** every pathway takes 18 months of permitting and 12 of construction, whatever the zone. Set `permitting_months` and `const_months` to change them.
95
+
94
96
  ### Input values
95
97
 
96
98
  The model doesn't check its inputs. A value it doesn't recognize falls back to a default or gives an empty result without an error, so use these exactly:
@@ -98,11 +100,15 @@ The model doesn't check its inputs. A value it doesn't recognize falls back to a
98
100
  | Input | Use | If it's wrong |
99
101
  | --- | --- | --- |
100
102
  | `program` | `"bill6"`, `"standard"`, `"bill7"`, `"tod"` or `"h201"`, lowercase | Anything else runs `"bill6"` |
101
- | `zone_class` | The district code as the Land Use Ordinance writes it, uppercase: `"A-2"`, `"AMX-3"`, `"BMX-4"`, `"B-2"`, `"I-2"`; Waikiki is `"Apart"` or `"ApartMix"` | `"a-2"` or an unknown code has no FAR, so `calcParcel` builds nothing; with your own `max_far` it builds, but without the zone's coverage, timing and retail rules |
103
+ | `zone_class` | The district code as the Land Use Ordinance writes it, uppercase: `"A-2"`, `"AMX-3"`, `"BMX-4"`, `"B-2"`, `"I-2"`; Waikiki is `"Apart"` or `"ApartMix"` | `"a-2"` or an unknown code has no FAR, so `calcParcel` builds nothing; with your own `max_far` it builds, but without the zone's coverage and retail rules |
102
104
  | `affordable_ami`, `market_ami` | `"ami_"` plus a bracket from 10 to 140 in steps of 10, such as `"ami_80"` | Other numbers (`"ami_85"`) are scaled from the 50% row and won't match the published HHFDC limits |
103
105
  | `unit_type` | `"studio_100"`, `"studio75_1br25"`, `"studio50_1br50"`, `"studio25_1br75"`, `"one_bb_100"`, `"one_br50_2br50"`, `"two_bb_100"` or `"third_each"` | Anything else is treated as all one-bedroom |
104
106
  | `affordability` (201H) | `0.51` or more | Below 50% + 1 of units, `calcSingle` throws |
105
107
  | `permanent_rate`, `amort_years` | Above `0` | `0` makes the IRR, equity multiple, profit and ROI `NaN` |
108
+ | `grant_type` (Bill 7) | `"post_construct"` (default), `"pre_construct"` or `"none"` | Anything else pays no grant |
109
+ | `grant_amount` (Bill 7) | Dollars per unit. Leave it out for the ordinance amount: $50,000 pre-construction; $12,000 post-construction, or $40/sqft for units under 300 sqft | A value you pass applies to every unit, overriding the small-unit rule |
110
+ | `grant_units` (Bill 7) | `"all"` (default) or `"affordable"`: the units the pre-construction subsidy pays on. Post-construction always pays on affordable units | Anything else pays on all units |
111
+ | `prevailing_wage_premium`, `onsite_labor_share` (Bill 7) | Fractions: the extra cost of on-site labor at prevailing wages (default `0.15`), and on-site labor as a share of hard cost (default `0.25`). Pre-construction runs only | `onsite_labor_share` also caps the subsidy, so setting it too low cuts the subsidy |
106
112
 
107
113
  With `calcSingle` and `"tod"`, pass a `max_far`, or the model can't run the project without the open-space bonus to compare (see Model notes). `calcParcel` always passes one.
108
114
 
@@ -116,9 +122,10 @@ Every function returns a `ParcelOutput`. Some of its fields:
116
122
  | Units | `affordable_units`, `mkt_rent_units`, `afd_rent_units`, `mkt_sale_units`, `afd_sale_units` |
117
123
  | Parking | `parking_stalls`, split into `surface_stalls`, `ground_floor_stalls` and `podium_stalls`; `podium_levels`, `loading_spaces`, `bicycle_spaces` |
118
124
  | Costs | `hard_cost`, `soft_cost` (itemized in `soft_cost_breakdown`: permits, fees, water charges, construction management), `dev_cost`, `total_financing_cost` |
125
+ | Bill 7 grants | `pre_construction_subsidy`, `dev_post_grant`; pre-construction runs also report `pw_premium_cost` (prevailing wage cost, included in `hard_cost`), `subsidy_labor_cap` and `subsidy_cap_binds` |
119
126
  | Income | `egi` (effective gross income), `noi` (net operating income), `gdv` (gross development value) |
120
- | Returns | `dev_roc` (return on cost), `dev_levered_irr`, `dev_equity_multiple`, `dev_profit`, `dev_dscr` |
121
- | Land | `rlv` and `rlv_sqft`: the residual land value, or the most a developer could pay for the land and still hit their target return |
127
+ | Returns | `dev_roc` (return on cost: stabilized NOI over development cost net of grants, at face value), `dev_levered_irr`, `dev_equity_multiple`, `dev_profit`, `dev_dscr` |
128
+ | Land | `rlv` and `rlv_sqft`: the residual land value, or the most a developer could pay for the land and still hit their target return. `rlv_grants_pv` (grants discounted to the land purchase) and `rlv_land_carry` (the discount factor to stabilization) split it: `rlv = (gdv - rlv_selling_costs - rlv_project_cost - rlv_profit) / rlv_land_carry + rlv_grants_pv` |
122
129
 
123
130
  **Note:** Rates are fractions, so `0.058` means 5.8% and money is in dollars.
124
131
 
@@ -219,6 +226,8 @@ The package also exports the rules behind the model, such as the zoning FAR tabl
219
226
  - The model estimates whether a typical project could pencil, it does not design a specific building.
220
227
  - The open-space FAR bonus is optional
221
228
  - Where a zone offers one (B-2, BMX-3, BMX-4), `calcSingle` runs the project with and without it and keeps the one with the higher residual land value, or the bigger building when neither pencils. The bonus costs 1 sqft of public open space per 5 sqft of extra floor area, so it often isn't worth it on lower buildings. `open_space_bonus` on the result is the bonus taken.
229
+ - Bill 7's pre-construction subsidy comes with prevailing wages
230
+ - ROH § 32-5.2(c)(3) lets the subsidy pay only prevailing wages to every on-site worker under HRS Chapter 104. The model counts 25% of hard cost as on-site labor, charges a 15% premium on it (3.75% of hard cost), and caps the subsidy at that labor at prevailing wages. The 25% comes from the 2022 Economic Census for Hawaiʻi (DBEDT State Data Book, Tables 21.05–21.06). The post-construction grant (§ 32-4.2) has no wage condition.
222
231
 
223
232
  The model's logic and data sources are documented here:
224
233
 
package/dist/index.cjs CHANGED
@@ -309,8 +309,27 @@ var COMMERCIAL_COVERAGE = {
309
309
  };
310
310
  var GRANT = {
311
311
  pre_construct: 5e4,
312
- post_construct: 12e3
312
+ post_construct: 12e3,
313
+ small_unit_sqft: 300,
314
+ small_unit_per_sqft: 40
313
315
  };
316
+ var PREVAILING_WAGE = {
317
+ // on-site labor share of hard cost (2022 economic census, DBEDT data book
318
+ // tables 21.05-21.06: 22-27%)
319
+ onsite_labor_share: 0.25,
320
+ // prevailing wage premium on that labor (trey's memo; test 10% and 20%)
321
+ labor_premium: 0.15
322
+ };
323
+ function postGrantPerUnit(unit_sqft) {
324
+ return unit_sqft < GRANT.small_unit_sqft ? unit_sqft * GRANT.small_unit_per_sqft : GRANT.post_construct;
325
+ }
326
+ function mixPostGrant(mix_key) {
327
+ const mix = UNIT_MIX[mix_key] ?? UNIT_MIX["one_bb_100"];
328
+ return mix.reduce(
329
+ (sum, m) => sum + postGrantPerUnit(UNIT_SIZE[m.type] ?? 500) * m.weight,
330
+ 0
331
+ );
332
+ }
314
333
  var PARKING = {
315
334
  // default stalls per dwelling unit
316
335
  stalls_per_unit: 0,
@@ -1641,29 +1660,8 @@ function waikikiOpenSpaceFrac(zone_class, far) {
1641
1660
  function envelopeCoverage(zone_class, far, coverage) {
1642
1661
  return Math.min(coverage, 1 - waikikiOpenSpaceFrac(zone_class, far));
1643
1662
  }
1644
- function constructionMonthsByZone(zone_class) {
1645
- if (zone_class === "R-5" || zone_class === "A-1" || zone_class === "AMX-1" || zone_class === "low_rise")
1646
- return 18;
1647
- if (zone_class === "A-2" || zone_class === "AMX-2" || zone_class === "B-2" || zone_class === "mid_rise")
1648
- return 24;
1649
- if (zone_class === "A-3" || zone_class === "AMX-3" || zone_class === "BMX-3" || zone_class === "BMX-4" || ZONE_GROUPS.waikiki.includes(zone_class))
1650
- return 36;
1651
- return 18;
1652
- }
1653
- function resolveConstMonths(zone_class, override) {
1654
- if (override != null && override > 0) return override;
1655
- return constructionMonthsByZone(zone_class);
1656
- }
1657
- function permittingMonthsByZone(zone_class) {
1658
- if (zone_class === "A-1" || zone_class === "AMX-1" || zone_class === "low_rise")
1659
- return Math.round(425 / 30);
1660
- if (zone_class === "A-2" || zone_class === "AMX-2" || zone_class === "A-3" || zone_class === "AMX-3" || ZONE_GROUPS.waikiki.includes(zone_class) || zone_class === "mid_rise")
1661
- return Math.round(535 / 30);
1662
- if (zone_class === "B-2" || zone_class === "BMX-3" || zone_class === "BMX-4")
1663
- return Math.round(454 / 30);
1664
- if (zone_class === "R-5") return Math.round(353 / 30);
1665
- return 14;
1666
- }
1663
+ var DEFAULT_PERMITTING_MONTHS = 18;
1664
+ var DEFAULT_CONST_MONTHS = 12;
1667
1665
  function yardFootprint(lot_sqft, front, rear, side, ratio = 2) {
1668
1666
  const width = Math.sqrt(lot_sqft / ratio);
1669
1667
  const depth = ratio * width;
@@ -2151,23 +2149,24 @@ function calcLeaseupCarry(principal, rental_units, sale_units, annual_rate, leas
2151
2149
  final_principal: running_principal
2152
2150
  };
2153
2151
  }
2154
- function calcDevRLV(gdv, dev_cost, total_financing_cost, affordable_units, grant_type, lot_sqft, total_months, land_rate = DEFAULT_TARGET_RETURN, profit_margin = DEFAULT_PROFIT_MARGIN, grant_amount = GRANT.post_construct) {
2152
+ function calcDevRLV(gdv, dev_cost, total_financing_cost, grant_flows, lot_sqft, total_months, land_rate = DEFAULT_TARGET_RETURN, profit_margin = DEFAULT_PROFIT_MARGIN) {
2155
2153
  const selling_costs = gdv * FINANCING.disposition_cost;
2156
- const post_construction_grant = grant_type === "post_construct" ? affordable_units * grant_amount : 0;
2157
- const pre_construction_subsidy = grant_type === "pre_construct" ? affordable_units * grant_amount : 0;
2158
2154
  const project_cost = dev_cost + total_financing_cost;
2159
2155
  const profit = project_cost * profit_margin;
2160
- const residual = gdv - selling_costs + post_construction_grant - project_cost - profit;
2156
+ const residual = gdv - selling_costs - project_cost - profit;
2161
2157
  const land_carry = (1 + land_rate) ** (total_months / 12);
2162
- const rlv = residual / land_carry + pre_construction_subsidy;
2158
+ const grants_pv = grant_flows.reduce(
2159
+ (sum, g, m) => sum + g / (1 + land_rate) ** (m / 12),
2160
+ 0
2161
+ );
2162
+ const rlv = residual / land_carry + grants_pv;
2163
2163
  const rlv_sqft = rlv / lot_sqft;
2164
2164
  return {
2165
2165
  selling_costs: Math.round(selling_costs),
2166
- post_construction_grant,
2167
- pre_construction_subsidy,
2166
+ grants_pv: Math.round(grants_pv),
2168
2167
  profit: Math.round(profit),
2169
2168
  project_cost: Math.round(project_cost),
2170
- land_carry: Math.round(land_carry * 1e4) / 1e4,
2169
+ land_carry,
2171
2170
  rlv: Math.round(rlv),
2172
2171
  rlv_sqft: Math.round(rlv_sqft * 100) / 100
2173
2172
  };
@@ -2193,7 +2192,7 @@ function calcDscrMaxLoan(noi, permanent_rate, amort_years, min_dscr) {
2193
2192
  const annual_constant = calcAnnualLoanConstant(permanent_rate, amort_years);
2194
2193
  return noi > 0 && annual_constant > 0 ? noi / (min_dscr * annual_constant) : 0;
2195
2194
  }
2196
- function sizeConstructionLoan(amount_to_finance, equity_floor, ltc_stabilized_balance, take_out, closing_grant = 0) {
2195
+ function sizeConstructionLoan(amount_to_finance, equity_floor, ltc_stabilized_balance, take_out) {
2197
2196
  const ltc_principal = amount_to_finance * (1 - equity_floor);
2198
2197
  const balance_per_dollar = ltc_principal > 0 ? ltc_stabilized_balance / ltc_principal : 0;
2199
2198
  const take_out_principal = take_out == null || balance_per_dollar <= 0 ? Infinity : take_out / balance_per_dollar;
@@ -2202,10 +2201,7 @@ function sizeConstructionLoan(amount_to_finance, equity_floor, ltc_stabilized_ba
2202
2201
  0,
2203
2202
  Math.min(ltc_principal, take_out_principal)
2204
2203
  );
2205
- const equity_amount = Math.max(
2206
- 0,
2207
- amount_to_finance - loan_principal - closing_grant
2208
- );
2204
+ const equity_amount = Math.max(0, amount_to_finance - loan_principal);
2209
2205
  return {
2210
2206
  loan_principal,
2211
2207
  equity_amount,
@@ -2325,7 +2321,8 @@ function zeroOutput(tmk, zone_class, lot_sqft, height_limit = 0) {
2325
2321
  total_predev_cost: 0,
2326
2322
  construction_equity: 0,
2327
2323
  rlv_selling_costs: 0,
2328
- rlv_post_grant: 0,
2324
+ rlv_grants_pv: 0,
2325
+ rlv_land_carry: 1,
2329
2326
  rlv_profit: 0,
2330
2327
  rlv_project_cost: 0,
2331
2328
  rlv: 0,
@@ -2513,7 +2510,7 @@ function bill6DevFlip(equity_amount, loan_balance, gdv, noi, dev_cost, total_fin
2513
2510
  dev_equity_invested: total_equity_invested
2514
2511
  };
2515
2512
  }
2516
- function bill6Hold(equity_amount, loan_balance, gdv, noi, egi, operating_expenses, dev_cost, total_financing_cost, total_predev_cost, hard_cost, total_months, land_cost_sqft, lot_sqft, permanent_rate, hold_years, amort_years, min_dscr, cap_rate, gross_sales) {
2513
+ function bill6Hold(equity_amount, loan_balance, gdv, noi, egi, operating_expenses, dev_cost, total_financing_cost, total_predev_cost, hard_cost, total_months, permitting_months, monthly_predev_spend, land_cost_sqft, lot_sqft, permanent_rate, hold_years, amort_years, min_dscr, cap_rate, gross_sales) {
2517
2514
  const land_cost = land_cost_sqft * lot_sqft;
2518
2515
  const land_value = land_cost;
2519
2516
  const improvement_value = hard_cost;
@@ -2540,7 +2537,14 @@ function bill6Hold(equity_amount, loan_balance, gdv, noi, egi, operating_expense
2540
2537
  const hold_months = hold_years * 12;
2541
2538
  const total_with_hold = total_months + hold_months;
2542
2539
  const cf = new Array(total_with_hold + 1).fill(0);
2543
- cf[0] = -equity_amount;
2540
+ equityFirstDraws(
2541
+ equity_amount,
2542
+ land_cost,
2543
+ monthly_predev_spend,
2544
+ permitting_months
2545
+ ).forEach((d, m) => {
2546
+ cf[m] += d;
2547
+ });
2544
2548
  cf[total_months] += excess_sale_cash;
2545
2549
  let current_egi = egi, current_opex = operating_expenses;
2546
2550
  let assessed_value = land_value + improvement_value;
@@ -2628,7 +2632,7 @@ function calcbill6Single(sliders) {
2628
2632
  const impact_fee_per_unit = sliders.impact_fee ?? FEES.impact.per_dwelling;
2629
2633
  const waivable_fee_basis = sliders.waivable_fee_basis ?? "market_units";
2630
2634
  const floor_height = sliders.floor_height ?? BILL6_DEFAULT_FLOOR_HEIGHT;
2631
- const permitting_months_val = sliders.permitting_months ?? permittingMonthsByZone(zone_class);
2635
+ const permitting_months_val = sliders.permitting_months ?? DEFAULT_PERMITTING_MONTHS;
2632
2636
  const setback = sliders.setback ?? 10;
2633
2637
  const hard_cost_per_sqft = sliders.hard_cost_per_sqft ?? 450;
2634
2638
  const parking_ratio = sliders.parking_ratio ?? (sliders.parking_exempt ? 0 : legalParkingRatio(unit_type));
@@ -2653,7 +2657,7 @@ function calcbill6Single(sliders) {
2653
2657
  );
2654
2658
  if (hard.max_units <= 0)
2655
2659
  return zeroOutput("hypothetical", zone_class, lot_sqft, height_limit);
2656
- const const_months = resolveConstMonths(zone_class, sliders.const_months);
2660
+ const const_months = sliders.const_months || DEFAULT_CONST_MONTHS;
2657
2661
  const cost_inflation_multiplier = FINANCING.cost_inflation ** ((permitting_months_val + const_months / 2) / 12);
2658
2662
  const hard_cost = hard.hard_cost * cost_inflation_multiplier;
2659
2663
  const soft = calcSoftCosts({
@@ -2757,10 +2761,8 @@ function calcbill6Single(sliders) {
2757
2761
  noi_result.gdv,
2758
2762
  loan.total_dev_cost,
2759
2763
  rlv_financing_cost,
2760
- 0,
2761
- // no affordable_units for grants in bill6
2762
- "none",
2763
- // no grant_type
2764
+ [],
2765
+ // no grants in bill6
2764
2766
  lot_sqft,
2765
2767
  total_months,
2766
2768
  sliders.target_return ?? DEFAULT_TARGET_RETURN,
@@ -2792,6 +2794,8 @@ function calcbill6Single(sliders) {
2792
2794
  predev.total_predev_cost,
2793
2795
  hard_cost,
2794
2796
  total_months,
2797
+ permitting_months_val,
2798
+ predev.monthly_predev_spend,
2795
2799
  land_cost,
2796
2800
  lot_sqft,
2797
2801
  permanent_rate,
@@ -2868,7 +2872,8 @@ function calcbill6Single(sliders) {
2868
2872
  cap_rate: noi_result.cap_rate,
2869
2873
  gdv: noi_result.gdv,
2870
2874
  rlv_selling_costs: rlv.selling_costs,
2871
- rlv_post_grant: rlv.post_construction_grant,
2875
+ rlv_grants_pv: rlv.grants_pv,
2876
+ rlv_land_carry: rlv.land_carry,
2872
2877
  rlv_profit: rlv.profit,
2873
2878
  rlv_project_cost: rlv.project_cost,
2874
2879
  rlv: rlv.rlv,
@@ -2903,7 +2908,7 @@ function calcBill7Single(sliders) {
2903
2908
  const min_dscr = sliders.min_dscr ?? 1.15;
2904
2909
  const opex_ratio = sliders.opex_ratio ?? 0.19;
2905
2910
  const grant_type = sliders.grant_type ?? DEFAULT_GRANT_TYPE;
2906
- const grant_amount = sliders.grant_amount ?? (grant_type === "pre_construct" ? GRANT.pre_construct : GRANT.post_construct);
2911
+ const grant_amount = sliders.grant_amount ?? (grant_type === "pre_construct" ? GRANT.pre_construct : mixPostGrant(unit_type));
2907
2912
  const include_retail = sliders.include_retail ?? false;
2908
2913
  const retail_rent = sliders.retail_rent ?? 3;
2909
2914
  const affordable_ami = sliders.affordable_ami ?? DEFAULT_AFFORDABLE_AMI;
@@ -2911,7 +2916,7 @@ function calcBill7Single(sliders) {
2911
2916
  const construction_rate = sliders.construction_rate ?? FINANCING.construction_rate;
2912
2917
  const has_zone = sliders.zone_class != null;
2913
2918
  const zone_class = sliders.zone_class ?? (height_limit <= 30 ? "low_rise" : "mid_rise");
2914
- const permitting_months_val = sliders.permitting_months ?? (has_zone ? permittingMonthsByZone(zone_class) : height_limit <= 30 ? Math.round(425 / 30) : Math.round(535 / 30));
2919
+ const permitting_months_val = sliders.permitting_months ?? DEFAULT_PERMITTING_MONTHS;
2915
2920
  const hard_cost_per_sqft = sliders.hard_cost_per_sqft ?? 450;
2916
2921
  const parking_ratio = sliders.parking_ratio ?? 0;
2917
2922
  const retail_share = sliders.retail_share ?? DEFAULT_RETAIL_SHARE;
@@ -2967,9 +2972,22 @@ function calcBill7Single(sliders) {
2967
2972
  const affordable_units = afd_rent_units;
2968
2973
  const parking_cost = env.parking_cost;
2969
2974
  const hard_cost_base = building_cost + parking_cost;
2970
- const const_months_val = has_zone ? resolveConstMonths(zone_class, sliders.const_months) : sliders.const_months ?? 12;
2975
+ const const_months_val = sliders.const_months || DEFAULT_CONST_MONTHS;
2971
2976
  const cost_inflation_multiplier = FINANCING.cost_inflation ** ((permitting_months_val + const_months_val / 2) / 12);
2972
- const hard_cost = hard_cost_base * cost_inflation_multiplier;
2977
+ const open_shop_hard_cost = hard_cost_base * cost_inflation_multiplier;
2978
+ const post_construction_grant = grant_type === "post_construct" ? affordable_units * grant_amount : 0;
2979
+ const is_pre = grant_type === "pre_construct";
2980
+ const pw_premium = is_pre ? sliders.prevailing_wage_premium ?? PREVAILING_WAGE.labor_premium : 0;
2981
+ const onsite_labor = (sliders.onsite_labor_share ?? PREVAILING_WAGE.onsite_labor_share) * open_shop_hard_cost;
2982
+ const pw_premium_cost = onsite_labor * pw_premium;
2983
+ const hard_cost = open_shop_hard_cost + pw_premium_cost;
2984
+ const subsidy_labor_cap = onsite_labor + pw_premium_cost;
2985
+ const subsidy_units = sliders.grant_units === "affordable" ? affordable_units : max_units;
2986
+ const subsidy_requested = is_pre ? subsidy_units * grant_amount : 0;
2987
+ const pre_construction_subsidy = Math.min(
2988
+ subsidy_requested,
2989
+ subsidy_labor_cap
2990
+ );
2973
2991
  const living_stories = env.living_stories;
2974
2992
  const soft = calcSoftCosts({
2975
2993
  hard_cost,
@@ -2988,20 +3006,15 @@ function calcBill7Single(sliders) {
2988
3006
  fu_per_unit: mixFixtureUnits(unit_type)
2989
3007
  });
2990
3008
  const soft_cost = soft.total;
2991
- const pre_construction_subsidy = grant_type === "pre_construct" ? affordable_units * grant_amount : 0;
2992
3009
  const construct_cost = hard_cost + soft_cost;
2993
3010
  const hard_cost_contingency = hard_cost * FINANCING.contingency;
2994
3011
  const total_dev_cost = hard_cost_contingency + soft_cost;
2995
3012
  const amount_to_finance = total_dev_cost;
2996
3013
  const land_cost_total = land_cost * lot_sqft;
2997
- const closing_grant = grant_type === "pre_construct" ? pre_construction_subsidy : 0;
2998
- const financeAt = (land_in_base, loan_principal2, grant = 0) => {
3014
+ const financeAt = (land_in_base, loan_principal2) => {
2999
3015
  const base = amount_to_finance + land_in_base;
3000
3016
  const loan_fee2 = loan_principal2 * FINANCING.loan_fee;
3001
- const equity_amount2 = Math.max(
3002
- 0,
3003
- base + loan_fee2 - loan_principal2 - grant
3004
- );
3017
+ const equity_amount2 = Math.max(0, base + loan_fee2 - loan_principal2);
3005
3018
  const predev2 = calcPredevEquity(
3006
3019
  construct_cost,
3007
3020
  permitting_months_val,
@@ -3033,6 +3046,12 @@ function calcBill7Single(sliders) {
3033
3046
  const ltc_pass = financeAt(0, amount_to_finance * (1 - equity));
3034
3047
  const total_carry_months = ltc_pass.leaseup.total_carry_months;
3035
3048
  const total_months = permitting_months_val + const_months_val + total_carry_months;
3049
+ const construction_end = permitting_months_val + const_months_val;
3050
+ const grant_flows = new Array(total_months + 1).fill(0);
3051
+ for (let m = permitting_months_val + 1; m <= construction_end; m++)
3052
+ grant_flows[m] += pre_construction_subsidy / const_months_val;
3053
+ grant_flows[construction_end] += post_construction_grant;
3054
+ const total_grants = pre_construction_subsidy + post_construction_grant;
3036
3055
  const afd_rent_multiplier = FINANCING.ami_escalation ** ((const_months_val + total_carry_months) / 12);
3037
3056
  const mkt_rent_multiplier = FINANCING.ami_escalation ** ((permitting_months_val + const_months_val + total_carry_months) / 12);
3038
3057
  const affordable_rent = mixRentByAmi(affordable_ami, unit_type) * afd_rent_multiplier;
@@ -3057,7 +3076,7 @@ function calcBill7Single(sliders) {
3057
3076
  const tax_exempt = Number(String(affordable_ami).replace("ami_", "")) <= 80;
3058
3077
  const property_tax = calcPropertyTax(
3059
3078
  land_cost * lot_sqft,
3060
- hard_cost,
3079
+ open_shop_hard_cost,
3061
3080
  tax_exempt
3062
3081
  );
3063
3082
  const tax = allocatePropertyTax(property_tax, residential_egi, retail_egi);
@@ -3081,17 +3100,12 @@ function calcBill7Single(sliders) {
3081
3100
  );
3082
3101
  const rlv_financing_cost = (rlv_sizing.dscr_binds ? financeAt(0, rlv_sizing.loan_principal) : ltc_pass).total_financing_cost;
3083
3102
  const deal_base = amount_to_finance + land_cost_total;
3084
- const deal_ltc = financeAt(
3085
- land_cost_total,
3086
- deal_base * (1 - equity),
3087
- closing_grant
3088
- );
3103
+ const deal_ltc = financeAt(land_cost_total, deal_base * (1 - equity));
3089
3104
  const sizing = sizeConstructionLoan(
3090
3105
  deal_base,
3091
3106
  equity,
3092
3107
  deal_ltc.leaseup.final_principal,
3093
- take_out,
3094
- closing_grant
3108
+ take_out
3095
3109
  );
3096
3110
  const {
3097
3111
  equity_amount,
@@ -3101,28 +3115,24 @@ function calcBill7Single(sliders) {
3101
3115
  const_int,
3102
3116
  leaseup,
3103
3117
  total_financing_cost
3104
- } = sizing.dscr_binds ? financeAt(land_cost_total, sizing.loan_principal, closing_grant) : deal_ltc;
3118
+ } = sizing.dscr_binds ? financeAt(land_cost_total, sizing.loan_principal) : deal_ltc;
3105
3119
  const target_return = sliders.target_return ?? DEFAULT_TARGET_RETURN;
3106
3120
  const rlv = calcDevRLV(
3107
3121
  gdv,
3108
3122
  total_dev_cost,
3109
3123
  rlv_financing_cost,
3110
- affordable_units,
3111
- grant_type,
3124
+ grant_flows,
3112
3125
  lot_sqft,
3113
3126
  total_months,
3114
3127
  target_return,
3115
- sliders.profit_margin ?? DEFAULT_PROFIT_MARGIN,
3116
- grant_amount
3128
+ sliders.profit_margin ?? DEFAULT_PROFIT_MARGIN
3117
3129
  );
3118
3130
  const total_equity_invested = equity_amount;
3119
3131
  let dev;
3120
3132
  if (hold_years === 0) {
3121
3133
  const selling_costs = gdv * FINANCING.disposition_cost;
3122
- let post_grant = 0;
3123
- if (grant_type === "post_construct")
3124
- post_grant = affordable_units * grant_amount;
3125
- const equity_out = gdv - selling_costs - leaseup.final_principal + post_grant;
3134
+ const post_grant = post_construction_grant;
3135
+ const equity_out = gdv - selling_costs - leaseup.final_principal;
3126
3136
  const cashflows = new Array(total_months + 1).fill(0);
3127
3137
  equityFirstDraws(
3128
3138
  equity_amount,
@@ -3132,10 +3142,14 @@ function calcBill7Single(sliders) {
3132
3142
  ).forEach((d, m) => {
3133
3143
  cashflows[m] += d;
3134
3144
  });
3135
- cashflows[total_months] = equity_out;
3145
+ grant_flows.forEach((g, m) => {
3146
+ cashflows[m] += g;
3147
+ });
3148
+ cashflows[total_months] += equity_out;
3136
3149
  const levered_irr = solveIRR(cashflows, true);
3137
- const equity_multiple = equity_out / total_equity_invested;
3138
- const roi = (equity_out - total_equity_invested) / total_equity_invested;
3150
+ const cash_in = equity_out + total_grants;
3151
+ const equity_multiple = cash_in / total_equity_invested;
3152
+ const roi = (cash_in - total_equity_invested) / total_equity_invested;
3139
3153
  const total_dev_cost_full = total_dev_cost + land_cost_total + total_financing_cost;
3140
3154
  const profit = gdv - selling_costs - total_dev_cost_full + post_grant + pre_construction_subsidy;
3141
3155
  const capital_subsidy = post_grant + pre_construction_subsidy;
@@ -3156,7 +3170,7 @@ function calcBill7Single(sliders) {
3156
3170
  dev_roc: Math.round(roc * 1e4) / 1e4,
3157
3171
  dev_gdv: gdv,
3158
3172
  dev_selling_costs: Math.round(selling_costs),
3159
- dev_equity_out: Math.round(equity_out),
3173
+ dev_equity_out: Math.round(cash_in),
3160
3174
  dev_loan_payoff: Math.round(leaseup.final_principal),
3161
3175
  dev_perm_principal: null,
3162
3176
  dev_perm_rate: null,
@@ -3170,11 +3184,9 @@ function calcBill7Single(sliders) {
3170
3184
  dev_equity_invested: total_equity_invested
3171
3185
  };
3172
3186
  } else {
3173
- let post_grant = 0;
3174
- if (grant_type === "post_construct")
3175
- post_grant = affordable_units * grant_amount;
3187
+ const post_grant = post_construction_grant;
3176
3188
  const land_value = land_cost * lot_sqft;
3177
- const improvement_value = hard_cost;
3189
+ const improvement_value = open_shop_hard_cost;
3178
3190
  const tax_rate = FINANCING.rpt_residential;
3179
3191
  const yr1_prop_tax = tax_exempt ? land_value * tax_rate : (land_value + improvement_value) * tax_rate;
3180
3192
  const yr1_noi_after_tax = egi - operating_expenses - yr1_prop_tax;
@@ -3191,7 +3203,17 @@ function calcBill7Single(sliders) {
3191
3203
  const hold_months = hold_years * 12;
3192
3204
  const total_with_hold = total_months + hold_months;
3193
3205
  const cf = new Array(total_with_hold + 1).fill(0);
3194
- cf[0] = -equity_amount;
3206
+ equityFirstDraws(
3207
+ equity_amount,
3208
+ land_cost_total,
3209
+ predev.monthly_predev_spend,
3210
+ permitting_months_val
3211
+ ).forEach((d, m) => {
3212
+ cf[m] += d;
3213
+ });
3214
+ grant_flows.forEach((g, m) => {
3215
+ cf[m] += g;
3216
+ });
3195
3217
  let current_egi = egi;
3196
3218
  let current_opex = operating_expenses;
3197
3219
  let assessed_value = land_value + improvement_value;
@@ -3204,8 +3226,7 @@ function calcBill7Single(sliders) {
3204
3226
  }
3205
3227
  const prop_tax = tax_exempt && yr <= HOLD.tax_exemption_years ? land_value * tax_rate : assessed_value * tax_rate;
3206
3228
  const current_noi = current_egi - current_opex - prop_tax;
3207
- let levered_cf = current_noi - perm.annual_debt_svc;
3208
- if (yr === 1 && post_grant > 0) levered_cf += post_grant;
3229
+ const levered_cf = current_noi - perm.annual_debt_svc;
3209
3230
  const monthly_cf = levered_cf / 12;
3210
3231
  for (let mo = 1; mo <= 12; mo++) {
3211
3232
  cf[total_months + (yr - 1) * 12 + mo] = monthly_cf;
@@ -3288,6 +3309,9 @@ function calcBill7Single(sliders) {
3288
3309
  total_financing_cost,
3289
3310
  rlv_financing_cost,
3290
3311
  pre_construction_subsidy,
3312
+ pw_premium_cost,
3313
+ subsidy_labor_cap: is_pre ? subsidy_labor_cap : 0,
3314
+ subsidy_cap_binds: subsidy_requested > subsidy_labor_cap,
3291
3315
  equity_amount,
3292
3316
  dev_equity_pct_realized: sizing.equity_pct_realized,
3293
3317
  dev_dscr_binds: sizing.dscr_binds,
@@ -3319,7 +3343,8 @@ function calcBill7Single(sliders) {
3319
3343
  gdv,
3320
3344
  // RLV
3321
3345
  rlv_selling_costs: rlv.selling_costs,
3322
- rlv_post_grant: rlv.post_construction_grant,
3346
+ rlv_grants_pv: rlv.grants_pv,
3347
+ rlv_land_carry: rlv.land_carry,
3323
3348
  rlv_profit: rlv.profit,
3324
3349
  rlv_project_cost: rlv.project_cost,
3325
3350
  rlv: rlv.rlv,
@@ -3370,7 +3395,7 @@ function calcH201Single(sliders) {
3370
3395
  const permitting_months = sliders.permitting_months ?? H201_DEFAULT_PERMITTING_MO;
3371
3396
  const impact_fee_per_unit = sliders.impact_fee ?? 0;
3372
3397
  const waivable_fee_basis = sliders.waivable_fee_basis ?? "market_units";
3373
- const const_months = resolveConstMonths(zone_class, sliders.const_months);
3398
+ const const_months = sliders.const_months || DEFAULT_CONST_MONTHS;
3374
3399
  const hard_cost_per_sqft = sliders.hard_cost_per_sqft ?? 450;
3375
3400
  const parking_ratio = sliders.parking_ratio ?? (sliders.parking_exempt ? 0 : legalParkingRatio(unit_type));
3376
3401
  const retail_share = sliders.retail_share ?? DEFAULT_RETAIL_SHARE;
@@ -3549,10 +3574,8 @@ function calcH201Single(sliders) {
3549
3574
  gdv,
3550
3575
  dev_cost,
3551
3576
  rlv_financing_cost,
3552
- 0,
3553
- // no per-unit grant
3554
- "none",
3555
- // no grant type
3577
+ [],
3578
+ // no grants in 201H
3556
3579
  lot_sqft,
3557
3580
  total_months,
3558
3581
  sliders.target_return ?? DEFAULT_TARGET_RETURN,
@@ -3560,7 +3583,6 @@ function calcH201Single(sliders) {
3560
3583
  );
3561
3584
  const rlv = {
3562
3585
  ...rlv_base,
3563
- post_construction_grant: 0,
3564
3586
  rlv: rlv_base.rlv,
3565
3587
  rlv_sqft: Math.round(rlv_base.rlv / lot_sqft * 100) / 100
3566
3588
  };
@@ -3644,7 +3666,14 @@ function calcH201Single(sliders) {
3644
3666
  const hold_months = hold_years * 12;
3645
3667
  const total_with_hold = total_months + hold_months;
3646
3668
  const cf = new Array(total_with_hold + 1).fill(0);
3647
- cf[0] = -equity_amount;
3669
+ equityFirstDraws(
3670
+ equity_amount,
3671
+ land_cost_total,
3672
+ predev.monthly_predev_spend,
3673
+ permitting_months
3674
+ ).forEach((d, m) => {
3675
+ cf[m] += d;
3676
+ });
3648
3677
  cf[total_months] += excess_sale_cash;
3649
3678
  let current_egi = egi, current_opex = operating_expenses;
3650
3679
  let assessed_value = land_value + improvement_value;
@@ -3774,7 +3803,8 @@ function calcH201Single(sliders) {
3774
3803
  cap_rate,
3775
3804
  gdv: Math.round(gdv),
3776
3805
  rlv_selling_costs: rlv.selling_costs,
3777
- rlv_post_grant: 0,
3806
+ rlv_grants_pv: rlv.grants_pv,
3807
+ rlv_land_carry: rlv.land_carry,
3778
3808
  rlv_profit: rlv.profit,
3779
3809
  rlv_project_cost: rlv.project_cost,
3780
3810
  rlv: rlv.rlv,
@@ -3965,7 +3995,7 @@ function standardDevFlip(equity_amount, loan_balance, gdv, noi, dev_cost, total_
3965
3995
  dev_equity_invested: total_equity_invested
3966
3996
  };
3967
3997
  }
3968
- function standardHold(equity_amount, loan_balance, gdv, noi, egi, operating_expenses, dev_cost, total_financing_cost, total_predev_cost, hard_cost, total_months, land_cost_sqft, lot_sqft, permanent_rate, hold_years, amort_years, min_dscr, cap_rate, gross_sales) {
3998
+ function standardHold(equity_amount, loan_balance, gdv, noi, egi, operating_expenses, dev_cost, total_financing_cost, total_predev_cost, hard_cost, total_months, permitting_months, monthly_predev_spend, land_cost_sqft, lot_sqft, permanent_rate, hold_years, amort_years, min_dscr, cap_rate, gross_sales) {
3969
3999
  const land_cost = land_cost_sqft * lot_sqft;
3970
4000
  const land_value = land_cost;
3971
4001
  const improvement_value = hard_cost;
@@ -3992,7 +4022,14 @@ function standardHold(equity_amount, loan_balance, gdv, noi, egi, operating_expe
3992
4022
  const hold_months = hold_years * 12;
3993
4023
  const total_with_hold = total_months + hold_months;
3994
4024
  const cf = new Array(total_with_hold + 1).fill(0);
3995
- cf[0] = -equity_amount;
4025
+ equityFirstDraws(
4026
+ equity_amount,
4027
+ land_cost,
4028
+ monthly_predev_spend,
4029
+ permitting_months
4030
+ ).forEach((d, m) => {
4031
+ cf[m] += d;
4032
+ });
3996
4033
  cf[total_months] += excess_sale_cash;
3997
4034
  let current_egi = egi, current_opex = operating_expenses;
3998
4035
  let assessed_value = land_value + improvement_value;
@@ -4080,7 +4117,7 @@ function calcStandardSingle(sliders) {
4080
4117
  const impact_fee_per_unit = sliders.impact_fee ?? FEES.impact.per_dwelling;
4081
4118
  const waivable_fee_basis = sliders.waivable_fee_basis ?? "market_units";
4082
4119
  const floor_height = sliders.floor_height ?? STD_DEFAULT_FLOOR_HEIGHT;
4083
- const permitting_months_val = sliders.permitting_months ?? permittingMonthsByZone(zone_class);
4120
+ const permitting_months_val = sliders.permitting_months ?? DEFAULT_PERMITTING_MONTHS;
4084
4121
  const setback = sliders.setback ?? 10;
4085
4122
  const hard_cost_per_sqft = sliders.hard_cost_per_sqft ?? 450;
4086
4123
  const parking_ratio = sliders.parking_ratio ?? (sliders.parking_exempt ? 0 : legalParkingRatio(unit_type));
@@ -4105,7 +4142,7 @@ function calcStandardSingle(sliders) {
4105
4142
  );
4106
4143
  if (hard.max_units <= 0)
4107
4144
  return zeroOutput("hypothetical", zone_class, lot_sqft, height_limit);
4108
- const const_months = resolveConstMonths(zone_class, sliders.const_months);
4145
+ const const_months = sliders.const_months || DEFAULT_CONST_MONTHS;
4109
4146
  const cost_inflation_multiplier = FINANCING.cost_inflation ** ((permitting_months_val + const_months / 2) / 12);
4110
4147
  const hard_cost = hard.hard_cost * cost_inflation_multiplier;
4111
4148
  const soft = calcSoftCosts({
@@ -4209,10 +4246,8 @@ function calcStandardSingle(sliders) {
4209
4246
  noi_result.gdv,
4210
4247
  loan.total_dev_cost,
4211
4248
  rlv_financing_cost,
4212
- 0,
4213
- // no affordable_units for grants in Standard
4214
- "none",
4215
- // no grant_type
4249
+ [],
4250
+ // no grants in Standard
4216
4251
  lot_sqft,
4217
4252
  total_months,
4218
4253
  sliders.target_return ?? DEFAULT_TARGET_RETURN,
@@ -4244,6 +4279,8 @@ function calcStandardSingle(sliders) {
4244
4279
  predev.total_predev_cost,
4245
4280
  hard_cost,
4246
4281
  total_months,
4282
+ permitting_months_val,
4283
+ predev.monthly_predev_spend,
4247
4284
  land_cost,
4248
4285
  lot_sqft,
4249
4286
  permanent_rate,
@@ -4320,7 +4357,8 @@ function calcStandardSingle(sliders) {
4320
4357
  cap_rate: noi_result.cap_rate,
4321
4358
  gdv: noi_result.gdv,
4322
4359
  rlv_selling_costs: rlv.selling_costs,
4323
- rlv_post_grant: rlv.post_construction_grant,
4360
+ rlv_grants_pv: rlv.grants_pv,
4361
+ rlv_land_carry: rlv.land_carry,
4324
4362
  rlv_profit: rlv.profit,
4325
4363
  rlv_project_cost: rlv.project_cost,
4326
4364
  rlv: rlv.rlv,
@@ -4363,11 +4401,11 @@ function calcTodSingle(sliders) {
4363
4401
  const affordable_ami = sliders.affordable_ami ?? TOD_DEFAULT_AFFORDABLE_AMI;
4364
4402
  const market_ami = sliders.market_ami ?? TOD_DEFAULT_MARKET_AMI;
4365
4403
  const construction_rate = sliders.construction_rate ?? FINANCING.construction_rate;
4366
- const permitting_months = sliders.permitting_months ?? 18;
4404
+ const permitting_months = sliders.permitting_months ?? DEFAULT_PERMITTING_MONTHS;
4367
4405
  const impact_fee_per_unit = sliders.impact_fee ?? FEES.impact.per_dwelling;
4368
4406
  const waivable_fee_basis = sliders.waivable_fee_basis ?? "market_units";
4369
4407
  const park = sliders.park ?? "in_lieu";
4370
- const const_months = resolveConstMonths(zone_class, sliders.const_months);
4408
+ const const_months = sliders.const_months || DEFAULT_CONST_MONTHS;
4371
4409
  const opex_ratio = sliders.opex_ratio ?? 0.3;
4372
4410
  const hard_cost_per_sqft = sliders.hard_cost_per_sqft ?? 450;
4373
4411
  const parking_ratio = sliders.parking_ratio ?? PARKING.stalls_per_unit;
@@ -4555,8 +4593,7 @@ function calcTodSingle(sliders) {
4555
4593
  gdv,
4556
4594
  dev_cost,
4557
4595
  rlv_financing_cost,
4558
- 0,
4559
- "none",
4596
+ [],
4560
4597
  lot_sqft,
4561
4598
  total_months,
4562
4599
  sliders.target_return ?? DEFAULT_TARGET_RETURN,
@@ -4642,7 +4679,14 @@ function calcTodSingle(sliders) {
4642
4679
  const hold_months = hold_years * 12;
4643
4680
  const total_with_hold = total_months + hold_months;
4644
4681
  const cf = new Array(total_with_hold + 1).fill(0);
4645
- cf[0] = -equity_amount;
4682
+ equityFirstDraws(
4683
+ equity_amount,
4684
+ land_cost_total,
4685
+ predev.monthly_predev_spend,
4686
+ permitting_months
4687
+ ).forEach((d, m) => {
4688
+ cf[m] += d;
4689
+ });
4646
4690
  cf[total_months] += excess_sale_cash;
4647
4691
  let current_egi = egi, current_opex = operating_expenses;
4648
4692
  let assessed_value = land_value + improvement_value;
@@ -4772,7 +4816,8 @@ function calcTodSingle(sliders) {
4772
4816
  cap_rate,
4773
4817
  gdv: Math.round(gdv),
4774
4818
  rlv_selling_costs: rlv.selling_costs,
4775
- rlv_post_grant: rlv.post_construction_grant,
4819
+ rlv_grants_pv: rlv.grants_pv,
4820
+ rlv_land_carry: rlv.land_carry,
4776
4821
  rlv_profit: rlv.profit,
4777
4822
  rlv_project_cost: rlv.project_cost,
4778
4823
  rlv: rlv.rlv,
@@ -5044,8 +5089,9 @@ var DEFAULT_INPUTS = (() => {
5044
5089
  retail_rent: 3,
5045
5090
  retail_share: 0.1,
5046
5091
  building_area_per_unit: 800,
5092
+ // no grant_amount: the model takes it from grant_type (GRANT), so a
5093
+ // pre-construction run gets $50,000 rather than the post-construction $12,000
5047
5094
  grant_type: "post_construct",
5048
- grant_amount: 12e3,
5049
5095
  hard_cost_per_sqft: 450,
5050
5096
  land_cost: 130,
5051
5097
  opex_ratio: 0.19,