///|
pub(all) enum CostTier {
Economy
Standard
Durable
Showcase
} derive(Eq, Debug)
///|
pub(all) struct CostProfile {
tier : CostTier
currency : String
construction_per_m2 : Double
excavation_per_m3 : Double
media_per_m3 : Double
planting_per_m2 : Double
pretreatment_each : Double
inspection_per_visit : Double
sediment_service_per_visit : Double
annual_maintenance_rate : Double
design_markup_rate : Double
contingency_rate : Double
lifespan_years : Int
} derive(Eq, Debug)
///|
pub(all) struct FacilityCost {
name : String
kind : FacilityKind
currency : String
footprint_area_m2 : Double
capital_cost : Double
annual_maintenance_cost : Double
lifecycle_cost : Double
annualized_cost : Double
cost_per_captured_m3 : Double
recommended_visits_per_year : Int
notes : Array[String]
} derive(Eq, Debug)
///|
pub(all) struct TrainCostSummary {
currency : String
facility_count : Int
total_capital_cost : Double
annual_maintenance_cost : Double
lifecycle_cost : Double
annualized_cost : Double
captured_volume_m3 : Double
cost_per_captured_m3 : Double
highest_cost_name : String
highest_cost : Double
items : Array[FacilityCost]
notes : Array[String]
} derive(Eq, Debug)
///|
pub fn default_cost_profile() -> CostProfile {
cost_profile(Standard)
}
///|
pub fn cost_profile(tier : CostTier) -> CostProfile {
match tier {
Economy =>
cost_profile_detail(
tier, "CNY", 380.0, 90.0, 240.0, 55.0, 800.0, 120.0, 420.0, 0.035, 0.08,
0.10, 10,
)
Standard =>
cost_profile_detail(
tier, "CNY", 520.0, 120.0, 320.0, 85.0, 1200.0, 180.0, 650.0, 0.045, 0.10,
0.12, 12,
)
Durable =>
cost_profile_detail(
tier, "CNY", 720.0, 160.0, 420.0, 120.0, 1800.0, 220.0, 760.0, 0.040, 0.12,
0.15, 15,
)
Showcase =>
cost_profile_detail(
tier, "CNY", 960.0, 210.0, 560.0, 180.0, 2600.0, 280.0, 920.0, 0.055, 0.15,
0.18, 15,
)
}
}
///|
pub fn custom_cost_profile(
currency : String,
construction_per_m2 : Double,
excavation_per_m3 : Double,
media_per_m3 : Double,
planting_per_m2 : Double,
pretreatment_each : Double,
inspection_per_visit : Double,
sediment_service_per_visit : Double,
annual_maintenance_rate : Double,
design_markup_rate : Double,
contingency_rate : Double,
lifespan_years : Int,
) -> CostProfile {
cost_profile_detail(
Standard,
currency,
construction_per_m2,
excavation_per_m3,
media_per_m3,
planting_per_m2,
pretreatment_each,
inspection_per_visit,
sediment_service_per_visit,
annual_maintenance_rate,
design_markup_rate,
contingency_rate,
lifespan_years,
)
}
///|
pub fn estimate_facility_cost(
contribution : FacilityContribution,
profile : CostProfile,
) -> Result[FacilityCost, SpongeError] {
match validate_cost_profile(profile) {
Some(error) => Err(error)
None => {
let storage_depth_m = storage_depth_from_contribution(contribution)
let storage_volume_m3 = contribution.storage_capacity_m3
let base = contribution.footprint_area_m2 * profile.construction_per_m2
let excavation = storage_volume_m3 * profile.excavation_per_m3
let media = storage_volume_m3 *
profile.media_per_m3 *
media_multiplier(contribution.kind)
let planting = contribution.footprint_area_m2 *
profile.planting_per_m2 *
planting_multiplier(contribution.kind)
let pretreatment = profile.pretreatment_each *
pretreatment_multiplier(contribution.kind)
let direct_cost = base + excavation + media + planting + pretreatment
let markup = direct_cost *
(profile.design_markup_rate + profile.contingency_rate)
let capital = direct_cost + markup
let visits = recommended_visits(contribution)
let visit_cost = visits.to_double() * profile.inspection_per_visit
let sediment_cost = profile.sediment_service_per_visit *
sediment_service_multiplier(contribution)
let annual_maintenance = capital * profile.annual_maintenance_rate +
visit_cost +
sediment_cost
let lifecycle = capital +
annual_maintenance * profile.lifespan_years.to_double()
let annualized = if profile.lifespan_years <= 0 {
lifecycle
} else {
lifecycle / profile.lifespan_years.to_double()
}
let cost_per_capture = if contribution.captured_volume_m3 <= 0.0 {
lifecycle
} else {
lifecycle / contribution.captured_volume_m3
}
Ok({
name: contribution.name,
kind: contribution.kind,
currency: profile.currency,
footprint_area_m2: contribution.footprint_area_m2,
capital_cost: capital,
annual_maintenance_cost: annual_maintenance,
lifecycle_cost: lifecycle,
annualized_cost: annualized,
cost_per_captured_m3: cost_per_capture,
recommended_visits_per_year: visits,
notes: cost_notes(contribution, storage_depth_m, cost_per_capture),
})
}
}
}
///|
pub fn estimate_treatment_train_cost(
plan : TreatmentTrainPlan,
profile : CostProfile,
) -> Result[TrainCostSummary, SpongeError] {
match validate_cost_profile(profile) {
Some(error) => Err(error)
None => {
let items : Array[FacilityCost] = []
let notes : Array[String] = []
let mut capital = 0.0
let mut annual = 0.0
let mut lifecycle = 0.0
let mut highest_name = ""
let mut highest_cost = -1.0
for contribution in plan.contributions {
match estimate_facility_cost(contribution, profile) {
Err(error) => return Err(error)
Ok(item) => {
if item.capital_cost > highest_cost {
highest_name = item.name
highest_cost = item.capital_cost
}
capital = capital + item.capital_cost
annual = annual + item.annual_maintenance_cost
lifecycle = lifecycle + item.lifecycle_cost
items.push(item)
}
}
}
if plan.capture_ratio < 0.80 {
notes.push("capital plan has low hydrologic efficiency")
}
if plan.bypass_volume_m3 > 0.0 {
notes.push("cost summary includes an overflow remainder")
}
if highest_cost > capital * 0.60 && items.length() > 1 {
notes.push("one facility dominates capital cost")
}
let annualized = if profile.lifespan_years <= 0 {
lifecycle
} else {
lifecycle / profile.lifespan_years.to_double()
}
let cost_per_capture = if plan.total_captured_m3 <= 0.0 {
lifecycle
} else {
lifecycle / plan.total_captured_m3
}
Ok({
currency: profile.currency,
facility_count: items.length(),
total_capital_cost: capital,
annual_maintenance_cost: annual,
lifecycle_cost: lifecycle,
annualized_cost: annualized,
captured_volume_m3: plan.total_captured_m3,
cost_per_captured_m3: cost_per_capture,
highest_cost_name: highest_name,
highest_cost,
items,
notes,
})
}
}
}
///|
pub fn budget_fit_score(
summary : TrainCostSummary,
capital_budget : Double,
annual_budget : Double,
) -> Result[Int, SpongeError] {
if capital_budget <= 0.0 || annual_budget <= 0.0 {
return Err(InvalidCostProfile)
}
let capital_ratio = summary.total_capital_cost / capital_budget
let annual_ratio = summary.annual_maintenance_cost / annual_budget
let capture_bonus = if summary.captured_volume_m3 >= 10.0 {
15
} else if summary.captured_volume_m3 >= 5.0 {
10
} else {
5
}
let capital_points = ratio_points(capital_ratio, 45)
let annual_points = ratio_points(annual_ratio, 35)
Ok(cost_clamp_int(capital_points + annual_points + capture_bonus, 0, 100))
}
///|
pub fn CostTier::label(self : CostTier) -> String {
match self {
Economy => "economy"
Standard => "standard"
Durable => "durable"
Showcase => "showcase"
}
}
///|
pub fn CostProfile::to_json(self : CostProfile) -> String {
let out = StringBuilder::new()
out.write_string("{")
out.write_string("\"tier\":\"" + self.tier.label() + "\",")
out.write_string("\"currency\":\"" + escape_json(self.currency) + "\",")
out.write_string(
"\"construction_per_m2\":" + self.construction_per_m2.to_string() + ",",
)
out.write_string(
"\"excavation_per_m3\":" + self.excavation_per_m3.to_string() + ",",
)
out.write_string("\"media_per_m3\":" + self.media_per_m3.to_string() + ",")
out.write_string(
"\"planting_per_m2\":" + self.planting_per_m2.to_string() + ",",
)
out.write_string(
"\"pretreatment_each\":" + self.pretreatment_each.to_string() + ",",
)
out.write_string(
"\"inspection_per_visit\":" + self.inspection_per_visit.to_string() + ",",
)
out.write_string(
"\"sediment_service_per_visit\":" +
self.sediment_service_per_visit.to_string() +
",",
)
out.write_string(
"\"annual_maintenance_rate\":" +
self.annual_maintenance_rate.to_string() +
",",
)
out.write_string(
"\"design_markup_rate\":" + self.design_markup_rate.to_string() + ",",
)
out.write_string(
"\"contingency_rate\":" + self.contingency_rate.to_string() + ",",
)
out.write_string("\"lifespan_years\":" + self.lifespan_years.to_string())
out.write_string("}")
out.to_string()
}
///|
pub fn FacilityCost::to_json(self : FacilityCost) -> String {
let out = StringBuilder::new()
out.write_string("{")
out.write_string("\"name\":\"" + escape_json(self.name) + "\",")
out.write_string("\"kind\":\"" + self.kind.label() + "\",")
out.write_string("\"currency\":\"" + escape_json(self.currency) + "\",")
out.write_string(
"\"footprint_area_m2\":" + self.footprint_area_m2.to_string() + ",",
)
out.write_string("\"capital_cost\":" + self.capital_cost.to_string() + ",")
out.write_string(
"\"annual_maintenance_cost\":" +
self.annual_maintenance_cost.to_string() +
",",
)
out.write_string(
"\"lifecycle_cost\":" + self.lifecycle_cost.to_string() + ",",
)
out.write_string(
"\"annualized_cost\":" + self.annualized_cost.to_string() + ",",
)
out.write_string(
"\"cost_per_captured_m3\":" + self.cost_per_captured_m3.to_string() + ",",
)
out.write_string(
"\"recommended_visits_per_year\":" +
self.recommended_visits_per_year.to_string() +
",",
)
out.write_string("\"notes\":" + cost_string_array_to_json(self.notes))
out.write_string("}")
out.to_string()
}
///|
pub fn TrainCostSummary::to_json(self : TrainCostSummary) -> String {
let out = StringBuilder::new()
out.write_string("{")
out.write_string("\"currency\":\"" + escape_json(self.currency) + "\",")
out.write_string(
"\"facility_count\":" + self.facility_count.to_string() + ",",
)
out.write_string(
"\"total_capital_cost\":" + self.total_capital_cost.to_string() + ",",
)
out.write_string(
"\"annual_maintenance_cost\":" +
self.annual_maintenance_cost.to_string() +
",",
)
out.write_string(
"\"lifecycle_cost\":" + self.lifecycle_cost.to_string() + ",",
)
out.write_string(
"\"annualized_cost\":" + self.annualized_cost.to_string() + ",",
)
out.write_string(
"\"captured_volume_m3\":" + self.captured_volume_m3.to_string() + ",",
)
out.write_string(
"\"cost_per_captured_m3\":" + self.cost_per_captured_m3.to_string() + ",",
)
out.write_string(
"\"highest_cost_name\":\"" + escape_json(self.highest_cost_name) + "\",",
)
out.write_string("\"highest_cost\":" + self.highest_cost.to_string() + ",")
out.write_string("\"notes\":" + cost_string_array_to_json(self.notes) + ",")
out.write_string("\"items\":[")
for index, item in self.items {
if index > 0 {
out.write_string(",")
}
out.write_string(item.to_json())
}
out.write_string("]}")
out.to_string()
}
///|
pub fn TrainCostSummary::to_markdown(self : TrainCostSummary) -> String {
let out = StringBuilder::new()
out.write_string("# SpongeKit Cost Summary\n\n")
out.write_string("- Currency: " + self.currency + "\n")
out.write_string("- Facilities: " + self.facility_count.to_string() + "\n")
out.write_string(
"- Capital cost: " + self.total_capital_cost.to_string() + "\n",
)
out.write_string(
"- Annual maintenance: " + self.annual_maintenance_cost.to_string() + "\n",
)
out.write_string(
"- Lifecycle cost: " + self.lifecycle_cost.to_string() + "\n",
)
out.write_string(
"- Cost per captured m3: " + self.cost_per_captured_m3.to_string() + "\n\n",
)
out.write_string("| Facility | Kind | Capital | Annual O&M | Visits/year |\n")
out.write_string("| --- | --- | ---: | ---: | ---: |\n")
for item in self.items {
out.write_string(
"| " +
item.name +
" | " +
item.kind.label() +
" | " +
item.capital_cost.to_string() +
" | " +
item.annual_maintenance_cost.to_string() +
" | " +
item.recommended_visits_per_year.to_string() +
" |\n",
)
}
if self.notes.length() > 0 {
out.write_string("\n## Notes\n\n")
for note in self.notes {
out.write_string("- " + note + "\n")
}
}
out.to_string()
}
///|
fn cost_profile_detail(
tier : CostTier,
currency : String,
construction_per_m2 : Double,
excavation_per_m3 : Double,
media_per_m3 : Double,
planting_per_m2 : Double,
pretreatment_each : Double,
inspection_per_visit : Double,
sediment_service_per_visit : Double,
annual_maintenance_rate : Double,
design_markup_rate : Double,
contingency_rate : Double,
lifespan_years : Int,
) -> CostProfile {
{
tier,
currency,
construction_per_m2,
excavation_per_m3,
media_per_m3,
planting_per_m2,
pretreatment_each,
inspection_per_visit,
sediment_service_per_visit,
annual_maintenance_rate,
design_markup_rate,
contingency_rate,
lifespan_years,
}
}
///|
fn validate_cost_profile(profile : CostProfile) -> SpongeError? {
if profile.construction_per_m2 < 0.0 ||
profile.excavation_per_m3 < 0.0 ||
profile.media_per_m3 < 0.0 ||
profile.planting_per_m2 < 0.0 ||
profile.pretreatment_each < 0.0 ||
profile.inspection_per_visit < 0.0 ||
profile.sediment_service_per_visit < 0.0 {
Some(InvalidCostProfile)
} else if profile.annual_maintenance_rate < 0.0 ||
profile.design_markup_rate < 0.0 ||
profile.contingency_rate < 0.0 {
Some(InvalidCostProfile)
} else if profile.lifespan_years <= 0 {
Some(InvalidCostProfile)
} else {
None
}
}
///|
fn media_multiplier(kind : FacilityKind) -> Double {
match kind {
RainGarden => 1.00
Bioswale => 0.85
PermeablePavement => 1.15
Cistern => 0.35
GreenRoof => 1.25
TreeTrench => 1.10
DryWell => 0.70
}
}
///|
fn planting_multiplier(kind : FacilityKind) -> Double {
match kind {
RainGarden => 1.00
Bioswale => 0.90
PermeablePavement => 0.10
Cistern => 0.05
GreenRoof => 1.35
TreeTrench => 1.20
DryWell => 0.20
}
}
///|
fn pretreatment_multiplier(kind : FacilityKind) -> Double {
match kind {
RainGarden => 1.00
Bioswale => 1.15
PermeablePavement => 0.75
Cistern => 0.90
GreenRoof => 0.45
TreeTrench => 1.20
DryWell => 0.65
}
}
///|
fn sediment_service_multiplier(contribution : FacilityContribution) -> Double {
if contribution.status != Ready {
1.20
} else if contribution.kind == Bioswale || contribution.kind == TreeTrench {
1.05
} else if contribution.kind == Cistern {
0.75
} else {
0.90
}
}
///|
fn recommended_visits(contribution : FacilityContribution) -> Int {
if contribution.status != Ready {
6
} else if contribution.kind == Cistern {
3
} else if contribution.kind == PermeablePavement {
4
} else if contribution.resilience_score < 60 {
5
} else {
4
}
}
///|
fn storage_depth_from_contribution(
contribution : FacilityContribution,
) -> Double {
if contribution.footprint_area_m2 <= 0.0 {
0.0
} else {
contribution.storage_capacity_m3 / contribution.footprint_area_m2
}
}
///|
fn cost_notes(
contribution : FacilityContribution,
storage_depth_m : Double,
cost_per_capture : Double,
) -> Array[String] {
let notes : Array[String] = []
if contribution.status != Ready {
notes.push("hydraulic review can increase construction contingency")
}
if storage_depth_m > 0.70 && contribution.kind != Cistern {
notes.push("deep storage may require excavation review")
}
if cost_per_capture > 50000.0 {
notes.push("high cost per captured cubic meter")
}
if contribution.footprint_area_m2 < 5.0 {
notes.push("small facility has mobilization overhead risk")
}
notes
}
///|
fn ratio_points(ratio : Double, max_points : Int) -> Int {
if ratio <= 0.70 {
max_points
} else if ratio <= 0.90 {
(max_points.to_double() * 0.85).to_int()
} else if ratio <= 1.00 {
(max_points.to_double() * 0.70).to_int()
} else if ratio <= 1.20 {
(max_points.to_double() * 0.35).to_int()
} else {
0
}
}
///|
fn cost_clamp_int(value : Int, low : Int, high : Int) -> Int {
if value < low {
low
} else if value > high {
high
} else {
value
}
}
///|
fn cost_string_array_to_json(values : Array[String]) -> String {
let out = StringBuilder::new()
out.write_string("[")
for index, value in values {
if index > 0 {
out.write_string(",")
}
out.write_string("\"" + escape_json(value) + "\"")
}
out.write_string("]")
out.to_string()
}