///|
pub(all) struct RetrofitScenario {
name : String
rainfall_multiplier : Double
runoff_multiplier : Double
infiltration_multiplier : Double
target_capture_ratio : Double
available_area_m2 : Double
sediment_load_index : Double
cost_tier : CostTier
capital_budget : Double
annual_budget : Double
} derive(Eq, Debug)
///|
pub(all) struct ScenarioResult {
name : String
adjusted_rain_mm : Double
adjusted_runoff_m3 : Double
recommended_area_m2 : Double
candidate_best_name : String
candidate_best_score : Int
treatment_capture_ratio : Double
treatment_bypass_m3 : Double
treatment_resilience_score : Int
maintenance_priority : MaintenancePriority
maintenance_risk_score : Int
total_capital_cost : Double
lifecycle_cost : Double
budget_fit_score : Int
overall_score : Int
status : PlanStatus
notes : Array[String]
} derive(Eq, Debug)
///|
pub(all) struct ScenarioComparison {
scenario_count : Int
best_name : String
worst_name : String
average_score : Double
max_runoff_m3 : Double
min_capture_ratio : Double
results : Array[ScenarioResult]
} derive(Eq, Debug)
///|
pub fn retrofit_scenario(
name : String,
rainfall_multiplier : Double,
available_area_m2 : Double,
) -> RetrofitScenario {
scenario_detail(
name,
rainfall_multiplier,
1.0,
1.0,
0.90,
available_area_m2,
0.35,
Standard,
60000.0,
9000.0,
)
}
///|
pub fn scenario_with_budget(
name : String,
rainfall_multiplier : Double,
runoff_multiplier : Double,
infiltration_multiplier : Double,
target_capture_ratio : Double,
available_area_m2 : Double,
sediment_load_index : Double,
cost_tier : CostTier,
capital_budget : Double,
annual_budget : Double,
) -> RetrofitScenario {
scenario_detail(
name, rainfall_multiplier, runoff_multiplier, infiltration_multiplier, target_capture_ratio,
available_area_m2, sediment_load_index, cost_tier, capital_budget, annual_budget,
)
}
///|
pub fn default_stress_scenarios(
available_area_m2 : Double,
) -> Array[RetrofitScenario] {
[
scenario_with_budget(
"baseline",
1.0,
1.0,
1.0,
0.90,
available_area_m2,
0.35,
Standard,
60000.0,
9000.0,
),
scenario_with_budget(
"compact retrofit",
0.85,
0.95,
1.10,
0.85,
available_area_m2 * 0.70,
0.25,
Economy,
42000.0,
6500.0,
),
scenario_with_budget(
"climate stress",
1.35,
1.10,
0.80,
0.90,
available_area_m2,
0.60,
Durable,
85000.0,
12000.0,
),
scenario_with_budget(
"showcase upgrade",
1.20,
1.00,
1.15,
0.95,
available_area_m2 * 1.25,
0.30,
Showcase,
120000.0,
16000.0,
),
]
}
///|
pub fn evaluate_retrofit_scenario(
catchments : Array[Catchment],
base_event : RainEvent,
spec : GardenSpec,
scenario : RetrofitScenario,
) -> Result[ScenarioResult, SpongeError] {
match validate_scenario(scenario) {
Some(error) => Err(error)
None => {
let adjusted_event = rain_event(
scenario.name + " rain",
base_event.depth_mm * scenario.rainfall_multiplier,
)
let adjusted_catchments = adjusted_catchments_for_scenario(
catchments,
scenario.runoff_multiplier,
)
let adjusted_spec = {
..spec,
infiltration_rate_mm_per_hr: spec.infiltration_rate_mm_per_hr *
scenario.infiltration_multiplier,
target_capture_ratio: scenario.target_capture_ratio,
}
match
size_rain_garden(adjusted_catchments, adjusted_event, adjusted_spec) {
Err(error) => Err(error)
Ok(garden_plan) =>
match scenario_candidates(scenario) {
Err(error) => Err(error)
Ok(candidates) =>
match
compare_candidate_areas(
adjusted_catchments, adjusted_event, adjusted_spec, candidates,
) {
Err(error) => Err(error)
Ok(candidate_comparison) =>
match
recommend_facility_mix(
adjusted_catchments,
adjusted_event,
scenario.available_area_m2,
) {
Err(error) => Err(error)
Ok(train_plan) =>
match
maintenance_plan(
garden_plan,
soil_profile(EngineeredMedia),
scenario.sediment_load_index,
service_age_for_scenario(scenario),
) {
Err(error) => Err(error)
Ok(maintenance) =>
match
estimate_treatment_train_cost(
train_plan,
cost_profile(scenario.cost_tier),
) {
Err(error) => Err(error)
Ok(cost_summary) =>
match
budget_fit_score(
cost_summary,
scenario.capital_budget,
scenario.annual_budget,
) {
Err(error) => Err(error)
Ok(budget_score) => {
let notes = scenario_notes(
garden_plan, train_plan, maintenance, cost_summary,
scenario,
)
let status = scenario_status(
garden_plan.status,
train_plan.status,
maintenance.priority,
budget_score,
)
let overall = scenario_overall_score(
garden_plan,
candidate_comparison.best_score,
train_plan,
maintenance,
budget_score,
notes.length(),
)
Ok({
name: scenario.name,
adjusted_rain_mm: adjusted_event.depth_mm,
adjusted_runoff_m3: garden_plan.runoff_volume_m3,
recommended_area_m2: garden_plan.recommended_area_m2,
candidate_best_name: candidate_comparison.best_name,
candidate_best_score: candidate_comparison.best_score,
treatment_capture_ratio: train_plan.capture_ratio,
treatment_bypass_m3: train_plan.bypass_volume_m3,
treatment_resilience_score: train_plan.resilience_score,
maintenance_priority: maintenance.priority,
maintenance_risk_score: maintenance.risk_score,
total_capital_cost: cost_summary.total_capital_cost,
lifecycle_cost: cost_summary.lifecycle_cost,
budget_fit_score: budget_score,
overall_score: overall,
status,
notes,
})
}
}
}
}
}
}
}
}
}
}
}
///|
pub fn compare_retrofit_scenarios(
catchments : Array[Catchment],
base_event : RainEvent,
spec : GardenSpec,
scenarios : Array[RetrofitScenario],
) -> Result[ScenarioComparison, SpongeError] {
if scenarios.length() == 0 {
return Err(InvalidScenarioInput)
}
let results : Array[ScenarioResult] = []
let mut best_name = ""
let mut worst_name = ""
let mut best_score = -1
let mut worst_score = 101
let mut score_sum = 0
let mut max_runoff = 0.0
let mut min_capture = 2.0
for scenario in scenarios {
match evaluate_retrofit_scenario(catchments, base_event, spec, scenario) {
Err(error) => return Err(error)
Ok(result) => {
if result.overall_score > best_score {
best_score = result.overall_score
best_name = result.name
}
if result.overall_score < worst_score {
worst_score = result.overall_score
worst_name = result.name
}
if result.adjusted_runoff_m3 > max_runoff {
max_runoff = result.adjusted_runoff_m3
}
if result.treatment_capture_ratio < min_capture {
min_capture = result.treatment_capture_ratio
}
score_sum = score_sum + result.overall_score
results.push(result)
}
}
}
Ok({
scenario_count: results.length(),
best_name,
worst_name,
average_score: score_sum.to_double() / results.length().to_double(),
max_runoff_m3: max_runoff,
min_capture_ratio: min_capture,
results,
})
}
///|
pub fn RetrofitScenario::to_json(self : RetrofitScenario) -> String {
let out = StringBuilder::new()
out.write_string("{")
out.write_string("\"name\":\"" + escape_json(self.name) + "\",")
out.write_string(
"\"rainfall_multiplier\":" + self.rainfall_multiplier.to_string() + ",",
)
out.write_string(
"\"runoff_multiplier\":" + self.runoff_multiplier.to_string() + ",",
)
out.write_string(
"\"infiltration_multiplier\":" +
self.infiltration_multiplier.to_string() +
",",
)
out.write_string(
"\"target_capture_ratio\":" + self.target_capture_ratio.to_string() + ",",
)
out.write_string(
"\"available_area_m2\":" + self.available_area_m2.to_string() + ",",
)
out.write_string(
"\"sediment_load_index\":" + self.sediment_load_index.to_string() + ",",
)
out.write_string("\"cost_tier\":\"" + self.cost_tier.label() + "\",")
out.write_string(
"\"capital_budget\":" + self.capital_budget.to_string() + ",",
)
out.write_string("\"annual_budget\":" + self.annual_budget.to_string())
out.write_string("}")
out.to_string()
}
///|
pub fn ScenarioResult::to_json(self : ScenarioResult) -> String {
let out = StringBuilder::new()
out.write_string("{")
out.write_string("\"name\":\"" + escape_json(self.name) + "\",")
out.write_string(
"\"adjusted_rain_mm\":" + self.adjusted_rain_mm.to_string() + ",",
)
out.write_string(
"\"adjusted_runoff_m3\":" + self.adjusted_runoff_m3.to_string() + ",",
)
out.write_string(
"\"recommended_area_m2\":" + self.recommended_area_m2.to_string() + ",",
)
out.write_string(
"\"candidate_best_name\":\"" + escape_json(self.candidate_best_name) + "\",",
)
out.write_string(
"\"candidate_best_score\":" + self.candidate_best_score.to_string() + ",",
)
out.write_string(
"\"treatment_capture_ratio\":" +
self.treatment_capture_ratio.to_string() +
",",
)
out.write_string(
"\"treatment_bypass_m3\":" + self.treatment_bypass_m3.to_string() + ",",
)
out.write_string(
"\"treatment_resilience_score\":" +
self.treatment_resilience_score.to_string() +
",",
)
out.write_string(
"\"maintenance_priority\":\"" + self.maintenance_priority.label() + "\",",
)
out.write_string(
"\"maintenance_risk_score\":" +
self.maintenance_risk_score.to_string() +
",",
)
out.write_string(
"\"total_capital_cost\":" + self.total_capital_cost.to_string() + ",",
)
out.write_string(
"\"lifecycle_cost\":" + self.lifecycle_cost.to_string() + ",",
)
out.write_string(
"\"budget_fit_score\":" + self.budget_fit_score.to_string() + ",",
)
out.write_string("\"overall_score\":" + self.overall_score.to_string() + ",")
out.write_string("\"status\":\"" + self.status.label() + "\",")
out.write_string("\"notes\":" + scenario_string_array_to_json(self.notes))
out.write_string("}")
out.to_string()
}
///|
pub fn ScenarioComparison::to_json(self : ScenarioComparison) -> String {
let out = StringBuilder::new()
out.write_string("{")
out.write_string(
"\"scenario_count\":" + self.scenario_count.to_string() + ",",
)
out.write_string("\"best_name\":\"" + escape_json(self.best_name) + "\",")
out.write_string("\"worst_name\":\"" + escape_json(self.worst_name) + "\",")
out.write_string("\"average_score\":" + self.average_score.to_string() + ",")
out.write_string("\"max_runoff_m3\":" + self.max_runoff_m3.to_string() + ",")
out.write_string(
"\"min_capture_ratio\":" + self.min_capture_ratio.to_string() + ",",
)
out.write_string("\"results\":[")
for index, result in self.results {
if index > 0 {
out.write_string(",")
}
out.write_string(result.to_json())
}
out.write_string("]}")
out.to_string()
}
///|
pub fn ScenarioComparison::to_markdown(self : ScenarioComparison) -> String {
let out = StringBuilder::new()
out.write_string("# SpongeKit Scenario Comparison\n\n")
out.write_string("- Scenarios: " + self.scenario_count.to_string() + "\n")
out.write_string("- Best: " + self.best_name + "\n")
out.write_string("- Worst: " + self.worst_name + "\n")
out.write_string("- Average score: " + self.average_score.to_string() + "\n")
out.write_string("- Max runoff: " + self.max_runoff_m3.to_string() + " m3\n")
out.write_string(
"- Min train capture: " + self.min_capture_ratio.to_string() + "\n\n",
)
out.write_string(
"| Scenario | Rain mm | Runoff m3 | Area m2 | Capture | Budget | Score | Status |\n",
)
out.write_string("| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |\n")
for result in self.results {
out.write_string(
"| " +
result.name +
" | " +
result.adjusted_rain_mm.to_string() +
" | " +
result.adjusted_runoff_m3.to_string() +
" | " +
result.recommended_area_m2.to_string() +
" | " +
result.treatment_capture_ratio.to_string() +
" | " +
result.budget_fit_score.to_string() +
" | " +
result.overall_score.to_string() +
" | " +
result.status.label() +
" |\n",
)
}
out.to_string()
}
///|
fn scenario_detail(
name : String,
rainfall_multiplier : Double,
runoff_multiplier : Double,
infiltration_multiplier : Double,
target_capture_ratio : Double,
available_area_m2 : Double,
sediment_load_index : Double,
cost_tier : CostTier,
capital_budget : Double,
annual_budget : Double,
) -> RetrofitScenario {
{
name,
rainfall_multiplier,
runoff_multiplier,
infiltration_multiplier,
target_capture_ratio,
available_area_m2,
sediment_load_index,
cost_tier,
capital_budget,
annual_budget,
}
}
///|
fn validate_scenario(scenario : RetrofitScenario) -> SpongeError? {
if scenario.rainfall_multiplier <= 0.0 ||
scenario.runoff_multiplier <= 0.0 ||
scenario.infiltration_multiplier <= 0.0 {
Some(InvalidScenarioInput)
} else if scenario.target_capture_ratio <= 0.0 ||
scenario.target_capture_ratio > 1.0 {
Some(InvalidScenarioInput)
} else if scenario.available_area_m2 <= 0.0 {
Some(InvalidScenarioInput)
} else if scenario.sediment_load_index < 0.0 ||
scenario.sediment_load_index > 1.0 {
Some(InvalidSedimentLoad)
} else if scenario.capital_budget <= 0.0 || scenario.annual_budget <= 0.0 {
Some(InvalidCostProfile)
} else {
None
}
}
///|
fn adjusted_catchments_for_scenario(
catchments : Array[Catchment],
runoff_multiplier : Double,
) -> Array[Catchment] {
let adjusted : Array[Catchment] = []
for catchment in catchments {
adjusted.push({
..catchment,
runoff_coefficient: scenario_clamp_double(
catchment.runoff_coefficient * runoff_multiplier,
0.0,
1.0,
),
})
}
adjusted
}
///|
fn scenario_candidates(
scenario : RetrofitScenario,
) -> Result[Array[AreaCandidate], SpongeError] {
if scenario.available_area_m2 <= 0.0 {
Err(InvalidScenarioInput)
} else {
Ok([
area_candidate("compact footprint", scenario.available_area_m2 * 0.50),
area_candidate("balanced footprint", scenario.available_area_m2 * 0.75),
area_candidate("full footprint", scenario.available_area_m2),
])
}
}
///|
fn service_age_for_scenario(scenario : RetrofitScenario) -> Int {
if scenario.sediment_load_index >= 0.70 {
14
} else if scenario.sediment_load_index >= 0.50 {
8
} else if scenario.rainfall_multiplier >= 1.25 {
6
} else {
3
}
}
///|
fn scenario_notes(
garden_plan : GardenPlan,
train_plan : TreatmentTrainPlan,
maintenance : MaintenancePlan,
cost_summary : TrainCostSummary,
scenario : RetrofitScenario,
) -> Array[String] {
let notes : Array[String] = []
if garden_plan.recommended_area_m2 > scenario.available_area_m2 {
notes.push("recommended garden area exceeds available footprint")
}
if train_plan.capture_ratio < scenario.target_capture_ratio {
notes.push("treatment train capture is below scenario target")
}
if train_plan.bypass_volume_m3 > 0.0 {
notes.push("scenario keeps bypass volume after treatment train")
}
if maintenance.priority == ServiceSoon || maintenance.priority == Restore {
notes.push("maintenance priority is elevated")
}
if cost_summary.total_capital_cost > scenario.capital_budget {
notes.push("capital cost exceeds budget")
}
if cost_summary.annual_maintenance_cost > scenario.annual_budget {
notes.push("annual maintenance exceeds budget")
}
if scenario.rainfall_multiplier >= 1.25 {
notes.push("rainfall multiplier represents a stress condition")
}
notes
}
///|
fn scenario_status(
garden_status : PlanStatus,
train_status : PlanStatus,
priority : MaintenancePriority,
budget_score : Int,
) -> PlanStatus {
if garden_status == Invalid || train_status == Invalid {
Invalid
} else if priority == Restore || budget_score < 35 {
NeedsReview
} else if garden_status != Ready || train_status != Ready {
NeedsReview
} else {
Ready
}
}
///|
fn scenario_overall_score(
garden_plan : GardenPlan,
candidate_score : Int,
train_plan : TreatmentTrainPlan,
maintenance : MaintenancePlan,
budget_score : Int,
note_count : Int,
) -> Int {
let area_fit = if garden_plan.recommended_area_m2 <= 0.0 {
5
} else if garden_plan.capture_ratio >= 1.0 {
12
} else {
(garden_plan.capture_ratio * 12.0).to_int()
}
let train_points = (scenario_clamp_double(train_plan.capture_ratio, 0.0, 1.0) *
28.0).to_int()
let candidate_points = (candidate_score.to_double() * 0.18).to_int()
let resilience_points = (train_plan.resilience_score.to_double() * 0.20).to_int()
let budget_points = (budget_score.to_double() * 0.22).to_int()
let maintenance_penalty = maintenance_penalty_points(maintenance.priority)
scenario_clamp_int(
area_fit +
train_points +
candidate_points +
resilience_points +
budget_points -
maintenance_penalty -
note_count * 3,
0,
100,
)
}
///|
fn maintenance_penalty_points(priority : MaintenancePriority) -> Int {
match priority {
Routine => 0
Watch => 5
ServiceSoon => 12
Restore => 22
}
}
///|
fn scenario_clamp_double(value : Double, low : Double, high : Double) -> Double {
if value < low {
low
} else if value > high {
high
} else {
value
}
}
///|
fn scenario_clamp_int(value : Int, low : Int, high : Int) -> Int {
if value < low {
low
} else if value > high {
high
} else {
value
}
}
///|
fn scenario_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()
}