///|
pub(all) enum FacilityKind {
RainGarden
Bioswale
PermeablePavement
Cistern
GreenRoof
TreeTrench
DryWell
} derive(Eq, Debug)
///|
pub(all) struct FacilitySpec {
kind : FacilityKind
name : String
footprint_area_m2 : Double
storage_depth_mm : Double
infiltration_rate_mm_per_hr : Double
drain_delay_hours : Double
pretreatment_score : Double
vegetation_score : Double
max_capture_ratio : Double
overflow_priority : Int
} derive(Eq, Debug)
///|
pub(all) struct FacilityContribution {
name : String
kind : FacilityKind
footprint_area_m2 : Double
storage_capacity_m3 : Double
event_runoff_m3 : Double
captured_volume_m3 : Double
bypass_volume_m3 : Double
capture_ratio : Double
drawdown_hours : Double
resilience_score : Int
status : PlanStatus
notes : Array[String]
} derive(Eq, Debug)
///|
pub(all) struct TreatmentTrainPlan {
event_name : String
facility_count : Int
runoff_volume_m3 : Double
footprint_area_m2 : Double
total_capacity_m3 : Double
total_captured_m3 : Double
bypass_volume_m3 : Double
capture_ratio : Double
weighted_drawdown_hours : Double
resilience_score : Int
status : PlanStatus
contributions : Array[FacilityContribution]
notes : Array[String]
} derive(Eq, Debug)
///|
pub fn facility_template(
kind : FacilityKind,
name : String,
footprint_area_m2 : Double,
) -> FacilitySpec {
match kind {
RainGarden =>
facility_spec(
kind, name, footprint_area_m2, 260.0, 25.0, 0.0, 0.75, 0.85, 1.00, 3,
)
Bioswale =>
facility_spec(
kind, name, footprint_area_m2, 180.0, 18.0, 1.0, 0.85, 0.75, 0.85, 2,
)
PermeablePavement =>
facility_spec(
kind, name, footprint_area_m2, 140.0, 10.0, 2.0, 0.60, 0.20, 0.70, 5,
)
Cistern =>
facility_spec(
kind, name, footprint_area_m2, 900.0, 2.0, 36.0, 0.50, 0.10, 0.95, 1,
)
GreenRoof =>
facility_spec(
kind, name, footprint_area_m2, 90.0, 8.0, 4.0, 0.70, 0.80, 0.55, 4,
)
TreeTrench =>
facility_spec(
kind, name, footprint_area_m2, 220.0, 16.0, 2.0, 0.80, 0.90, 0.80, 2,
)
DryWell =>
facility_spec(
kind, name, footprint_area_m2, 700.0, 30.0, 6.0, 0.35, 0.00, 0.90, 1,
)
}
}
///|
pub fn facility_with_hydraulics(
kind : FacilityKind,
name : String,
footprint_area_m2 : Double,
storage_depth_mm : Double,
infiltration_rate_mm_per_hr : Double,
drain_delay_hours : Double,
pretreatment_score : Double,
vegetation_score : Double,
max_capture_ratio : Double,
overflow_priority : Int,
) -> FacilitySpec {
facility_spec(
kind, name, footprint_area_m2, storage_depth_mm, infiltration_rate_mm_per_hr,
drain_delay_hours, pretreatment_score, vegetation_score, max_capture_ratio, overflow_priority,
)
}
///|
pub fn facility_storage_capacity_m3(
facility : FacilitySpec,
) -> Result[Double, SpongeError] {
match validate_facility(facility) {
Some(error) => Err(error)
None => Ok(facility.footprint_area_m2 * facility.storage_depth_mm / 1000.0)
}
}
///|
pub fn evaluate_facility(
event_runoff_m3 : Double,
facility : FacilitySpec,
) -> Result[FacilityContribution, SpongeError] {
if event_runoff_m3 < 0.0 {
return Err(InvalidScenarioInput)
}
match validate_facility(facility) {
Some(error) => Err(error)
None => {
let capacity = facility.footprint_area_m2 *
facility.storage_depth_mm /
1000.0
let max_event_capture = event_runoff_m3 * facility.max_capture_ratio
let captured = facility_min_double(capacity, max_event_capture)
let bypass = if event_runoff_m3 <= captured {
0.0
} else {
event_runoff_m3 - captured
}
let capture_ratio = if event_runoff_m3 <= 0.0 {
1.0
} else {
captured / event_runoff_m3
}
let drawdown = facility_drawdown_hours(facility)
let notes = facility_notes(facility, event_runoff_m3, captured, drawdown)
let status = facility_status(capture_ratio, drawdown, notes)
let resilience = facility_resilience_score(
facility,
capture_ratio,
drawdown,
notes.length(),
)
Ok({
name: facility.name,
kind: facility.kind,
footprint_area_m2: facility.footprint_area_m2,
storage_capacity_m3: capacity,
event_runoff_m3,
captured_volume_m3: captured,
bypass_volume_m3: bypass,
capture_ratio,
drawdown_hours: drawdown,
resilience_score: resilience,
status,
notes,
})
}
}
}
///|
pub fn evaluate_treatment_train(
catchments : Array[Catchment],
event : RainEvent,
facilities : Array[FacilitySpec],
) -> Result[TreatmentTrainPlan, SpongeError] {
if facilities.length() == 0 {
return Err(InvalidFacilitySpec)
}
match total_runoff_volume(catchments, event) {
Err(error) => Err(error)
Ok(runoff) => {
let contributions : Array[FacilityContribution] = []
let train_notes : Array[String] = []
let mut remaining = runoff.volume_m3
let mut footprint = 0.0
let mut total_capacity = 0.0
let mut captured_total = 0.0
let mut weighted_drawdown = 0.0
let mut resilience_sum = 0
let mut review_count = 0
for facility in facilities {
match evaluate_facility(remaining, facility) {
Err(error) => return Err(error)
Ok(contribution) => {
contributions.push(contribution)
remaining = contribution.bypass_volume_m3
footprint = footprint + contribution.footprint_area_m2
total_capacity = total_capacity + contribution.storage_capacity_m3
captured_total = captured_total + contribution.captured_volume_m3
weighted_drawdown = weighted_drawdown +
contribution.drawdown_hours * contribution.captured_volume_m3
resilience_sum = resilience_sum + contribution.resilience_score
if contribution.status != Ready {
review_count = review_count + 1
}
}
}
}
let capture_ratio = if runoff.volume_m3 <= 0.0 {
1.0
} else {
captured_total / runoff.volume_m3
}
let normalized_drawdown = if captured_total <= 0.0 {
0.0
} else {
weighted_drawdown / captured_total
}
let resilience = if facilities.length() == 0 {
0
} else {
resilience_sum / facilities.length()
}
if runoff.volume_m3 <= 0.0 {
train_notes.push("event creates no runoff for treatment train")
}
if capture_ratio < 0.80 {
train_notes.push("treatment train captures less than eighty percent")
} else if capture_ratio < 0.90 {
train_notes.push("treatment train is near the common capture target")
}
if remaining > 0.0 {
train_notes.push("overflow remains after the final facility")
}
if review_count > 0 {
train_notes.push("one or more facilities need review")
}
let status = if train_notes.length() == 0 { Ready } else { NeedsReview }
Ok({
event_name: event.name,
facility_count: facilities.length(),
runoff_volume_m3: runoff.volume_m3,
footprint_area_m2: footprint,
total_capacity_m3: total_capacity,
total_captured_m3: captured_total,
bypass_volume_m3: remaining,
capture_ratio,
weighted_drawdown_hours: normalized_drawdown,
resilience_score: resilience,
status,
contributions,
notes: train_notes,
})
}
}
}
///|
pub fn recommend_facility_mix(
catchments : Array[Catchment],
event : RainEvent,
available_area_m2 : Double,
) -> Result[TreatmentTrainPlan, SpongeError] {
if available_area_m2 <= 0.0 {
return Err(InvalidFacilitySpec)
}
let facilities = if available_area_m2 < 12.0 {
[
facility_template(
RainGarden,
"compact rain garden",
available_area_m2 * 0.60,
),
facility_template(Cistern, "overflow cistern", available_area_m2 * 0.40),
]
} else if available_area_m2 < 35.0 {
[
facility_template(
RainGarden,
"rain garden cell",
available_area_m2 * 0.50,
),
facility_template(TreeTrench, "tree trench", available_area_m2 * 0.30),
facility_template(
PermeablePavement,
"pavement apron",
available_area_m2 * 0.20,
),
]
} else {
[
facility_template(
Bioswale,
"street edge bioswale",
available_area_m2 * 0.35,
),
facility_template(
RainGarden,
"central rain garden",
available_area_m2 * 0.35,
),
facility_template(
PermeablePavement,
"permeable overflow path",
available_area_m2 * 0.20,
),
facility_template(
TreeTrench,
"tree trench reserve",
available_area_m2 * 0.10,
),
]
}
evaluate_treatment_train(catchments, event, facilities)
}
///|
pub fn FacilityKind::label(self : FacilityKind) -> String {
match self {
RainGarden => "rain_garden"
Bioswale => "bioswale"
PermeablePavement => "permeable_pavement"
Cistern => "cistern"
GreenRoof => "green_roof"
TreeTrench => "tree_trench"
DryWell => "dry_well"
}
}
///|
pub fn FacilitySpec::to_json(self : FacilitySpec) -> String {
let out = StringBuilder::new()
out.write_string("{")
out.write_string("\"kind\":\"" + self.kind.label() + "\",")
out.write_string("\"name\":\"" + escape_json(self.name) + "\",")
out.write_string(
"\"footprint_area_m2\":" + self.footprint_area_m2.to_string() + ",",
)
out.write_string(
"\"storage_depth_mm\":" + self.storage_depth_mm.to_string() + ",",
)
out.write_string(
"\"infiltration_rate_mm_per_hr\":" +
self.infiltration_rate_mm_per_hr.to_string() +
",",
)
out.write_string(
"\"drain_delay_hours\":" + self.drain_delay_hours.to_string() + ",",
)
out.write_string(
"\"pretreatment_score\":" + self.pretreatment_score.to_string() + ",",
)
out.write_string(
"\"vegetation_score\":" + self.vegetation_score.to_string() + ",",
)
out.write_string(
"\"max_capture_ratio\":" + self.max_capture_ratio.to_string() + ",",
)
out.write_string(
"\"overflow_priority\":" + self.overflow_priority.to_string(),
)
out.write_string("}")
out.to_string()
}
///|
pub fn FacilityContribution::to_json(self : FacilityContribution) -> 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(
"\"footprint_area_m2\":" + self.footprint_area_m2.to_string() + ",",
)
out.write_string(
"\"storage_capacity_m3\":" + self.storage_capacity_m3.to_string() + ",",
)
out.write_string(
"\"event_runoff_m3\":" + self.event_runoff_m3.to_string() + ",",
)
out.write_string(
"\"captured_volume_m3\":" + self.captured_volume_m3.to_string() + ",",
)
out.write_string(
"\"bypass_volume_m3\":" + self.bypass_volume_m3.to_string() + ",",
)
out.write_string("\"capture_ratio\":" + self.capture_ratio.to_string() + ",")
out.write_string(
"\"drawdown_hours\":" + self.drawdown_hours.to_string() + ",",
)
out.write_string(
"\"resilience_score\":" + self.resilience_score.to_string() + ",",
)
out.write_string("\"status\":\"" + self.status.label() + "\",")
out.write_string("\"notes\":" + facility_string_array_to_json(self.notes))
out.write_string("}")
out.to_string()
}
///|
pub fn TreatmentTrainPlan::to_json(self : TreatmentTrainPlan) -> String {
let out = StringBuilder::new()
out.write_string("{")
out.write_string("\"event\":\"" + escape_json(self.event_name) + "\",")
out.write_string(
"\"facility_count\":" + self.facility_count.to_string() + ",",
)
out.write_string(
"\"runoff_volume_m3\":" + self.runoff_volume_m3.to_string() + ",",
)
out.write_string(
"\"footprint_area_m2\":" + self.footprint_area_m2.to_string() + ",",
)
out.write_string(
"\"total_capacity_m3\":" + self.total_capacity_m3.to_string() + ",",
)
out.write_string(
"\"total_captured_m3\":" + self.total_captured_m3.to_string() + ",",
)
out.write_string(
"\"bypass_volume_m3\":" + self.bypass_volume_m3.to_string() + ",",
)
out.write_string("\"capture_ratio\":" + self.capture_ratio.to_string() + ",")
out.write_string(
"\"weighted_drawdown_hours\":" +
self.weighted_drawdown_hours.to_string() +
",",
)
out.write_string(
"\"resilience_score\":" + self.resilience_score.to_string() + ",",
)
out.write_string("\"status\":\"" + self.status.label() + "\",")
out.write_string(
"\"notes\":" + facility_string_array_to_json(self.notes) + ",",
)
out.write_string("\"contributions\":[")
for index, contribution in self.contributions {
if index > 0 {
out.write_string(",")
}
out.write_string(contribution.to_json())
}
out.write_string("]}")
out.to_string()
}
///|
pub fn TreatmentTrainPlan::to_markdown(self : TreatmentTrainPlan) -> String {
let out = StringBuilder::new()
out.write_string("# SpongeKit Treatment Train\n\n")
out.write_string("- Event: " + self.event_name + "\n")
out.write_string("- Status: " + self.status.label() + "\n")
out.write_string("- Facilities: " + self.facility_count.to_string() + "\n")
out.write_string(
"- Footprint: " + self.footprint_area_m2.to_string() + " m2\n",
)
out.write_string("- Runoff: " + self.runoff_volume_m3.to_string() + " m3\n")
out.write_string(
"- Captured: " + self.total_captured_m3.to_string() + " m3\n",
)
out.write_string("- Bypass: " + self.bypass_volume_m3.to_string() + " m3\n")
out.write_string("- Capture ratio: " + self.capture_ratio.to_string() + "\n")
out.write_string(
"- Resilience score: " + self.resilience_score.to_string() + "\n\n",
)
out.write_string(
"| Facility | Kind | Area m2 | Capture | Bypass m3 | Score |\n",
)
out.write_string("| --- | --- | ---: | ---: | ---: | ---: |\n")
for contribution in self.contributions {
out.write_string(
"| " +
contribution.name +
" | " +
contribution.kind.label() +
" | " +
contribution.footprint_area_m2.to_string() +
" | " +
contribution.capture_ratio.to_string() +
" | " +
contribution.bypass_volume_m3.to_string() +
" | " +
contribution.resilience_score.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 facility_spec(
kind : FacilityKind,
name : String,
footprint_area_m2 : Double,
storage_depth_mm : Double,
infiltration_rate_mm_per_hr : Double,
drain_delay_hours : Double,
pretreatment_score : Double,
vegetation_score : Double,
max_capture_ratio : Double,
overflow_priority : Int,
) -> FacilitySpec {
{
kind,
name,
footprint_area_m2,
storage_depth_mm,
infiltration_rate_mm_per_hr,
drain_delay_hours,
pretreatment_score,
vegetation_score,
max_capture_ratio,
overflow_priority,
}
}
///|
fn validate_facility(facility : FacilitySpec) -> SpongeError? {
if facility.footprint_area_m2 <= 0.0 {
Some(InvalidFacilitySpec)
} else if facility.storage_depth_mm <= 0.0 {
Some(InvalidFacilitySpec)
} else if facility.infiltration_rate_mm_per_hr <= 0.0 {
Some(InvalidInfiltrationRate)
} else if facility.drain_delay_hours < 0.0 {
Some(InvalidFacilitySpec)
} else if facility.pretreatment_score < 0.0 ||
facility.pretreatment_score > 1.0 {
Some(InvalidFacilitySpec)
} else if facility.vegetation_score < 0.0 || facility.vegetation_score > 1.0 {
Some(InvalidFacilitySpec)
} else if facility.max_capture_ratio <= 0.0 ||
facility.max_capture_ratio > 1.0 {
Some(InvalidFacilitySpec)
} else if facility.overflow_priority < 0 {
Some(InvalidFacilitySpec)
} else {
None
}
}
///|
fn facility_drawdown_hours(facility : FacilitySpec) -> Double {
facility.storage_depth_mm / facility.infiltration_rate_mm_per_hr +
facility.drain_delay_hours
}
///|
fn facility_notes(
facility : FacilitySpec,
event_runoff_m3 : Double,
captured_volume_m3 : Double,
drawdown_hours : Double,
) -> Array[String] {
let notes : Array[String] = []
if event_runoff_m3 <= 0.0 {
notes.push("no runoff is routed to this facility")
}
if captured_volume_m3 <= 0.0 && event_runoff_m3 > 0.0 {
notes.push("facility captures no volume under this event")
}
if drawdown_hours > 48.0 {
notes.push("drawdown exceeds forty eight hours")
} else if drawdown_hours > 24.0 {
notes.push("drawdown exceeds twenty four hours")
}
if facility.pretreatment_score < 0.40 {
notes.push("pretreatment is weak for sediment control")
}
if facility.vegetation_score < 0.30 &&
facility.kind != Cistern &&
facility.kind != DryWell {
notes.push("vegetation benefit is limited")
}
if facility.storage_depth_mm > 750.0 && facility.kind != Cistern {
notes.push("storage depth may need constructability review")
}
notes
}
///|
fn facility_status(
capture_ratio : Double,
drawdown_hours : Double,
notes : Array[String],
) -> PlanStatus {
if drawdown_hours > 48.0 {
NeedsReview
} else if capture_ratio <= 0.0 {
NeedsReview
} else if notes.length() >= 3 {
NeedsReview
} else {
Ready
}
}
///|
fn facility_resilience_score(
facility : FacilitySpec,
capture_ratio : Double,
drawdown_hours : Double,
note_count : Int,
) -> Int {
let capture_points = (facility_clamp_double(capture_ratio, 0.0, 1.0) * 45.0).to_int()
let pretreatment_points = (facility.pretreatment_score * 20.0).to_int()
let vegetation_points = (facility.vegetation_score * 15.0).to_int()
let drawdown_points = if drawdown_hours <= 12.0 {
15
} else if drawdown_hours <= 24.0 {
10
} else if drawdown_hours <= 48.0 {
4
} else {
0
}
let priority_bonus = if facility.overflow_priority <= 1 {
5
} else if facility.overflow_priority <= 3 {
3
} else {
0
}
facility_clamp_int(
capture_points +
pretreatment_points +
vegetation_points +
drawdown_points +
priority_bonus -
note_count * 4,
0,
100,
)
}
///|
fn facility_min_double(left : Double, right : Double) -> Double {
if left < right {
left
} else {
right
}
}
///|
fn facility_clamp_double(value : Double, low : Double, high : Double) -> Double {
if value < low {
low
} else if value > high {
high
} else {
value
}
}
///|
fn facility_clamp_int(value : Int, low : Int, high : Int) -> Int {
if value < low {
low
} else if value > high {
high
} else {
value
}
}
///|
fn facility_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()
}