///|
pub(all) enum SpongeError {
NonPositiveArea
NegativeRainfall
InvalidRunoffCoefficient
InvalidCurveNumber
InvalidInfiltrationRate
InvalidStorageDepth
InvalidSafetyFactor
InvalidCellLimit
InvalidWeight
InvalidCandidateArea
InvalidSedimentLoad
InvalidFacilitySpec
InvalidCostProfile
InvalidScenarioInput
} derive(Eq, Debug)
///|
pub(all) enum SurfaceKind {
Roof
Pavement
CompactedSoil
Lawn
Meadow
} derive(Eq, Debug)
///|
pub(all) enum PlanStatus {
Ready
NeedsReview
Invalid
} derive(Eq, Debug)
///|
pub(all) enum PlanIssueKind {
DrawdownTooSlow
CaptureBelowTarget
OversizedCell
NoRunoff
} derive(Eq, Debug)
///|
pub(all) struct RainEvent {
name : String
depth_mm : Double
} derive(Eq, Debug)
///|
pub(all) struct Catchment {
name : String
area_m2 : Double
runoff_coefficient : Double
} derive(Eq, Debug)
///|
pub(all) struct RunoffResult {
depth_mm : Double
volume_m3 : Double
volume_liters : Double
} derive(Eq, Debug)
///|
pub(all) struct CurveNumberResult {
rainfall_mm : Double
curve_number : Double
retention_mm : Double
initial_abstraction_mm : Double
runoff_depth_mm : Double
runoff_volume_m3 : Double
} derive(Eq, Debug)
///|
pub(all) struct GardenSpec {
ponding_depth_mm : Double
media_depth_mm : Double
media_porosity : Double
infiltration_rate_mm_per_hr : Double
safety_factor : Double
target_drawdown_hours : Double
target_capture_ratio : Double
max_cell_area_m2 : Double
} derive(Eq, Debug)
///|
pub(all) struct PlanIssue {
kind : PlanIssueKind
message : String
} derive(Eq, Debug)
///|
pub(all) struct GardenPlan {
status : PlanStatus
event_name : String
catchment_count : Int
catchment_area_m2 : Double
design_rain_mm : Double
runoff_volume_m3 : Double
storage_depth_mm : Double
required_area_m2 : Double
recommended_area_m2 : Double
cell_count : Int
cell_area_m2 : Double
drawdown_hours : Double
capture_ratio : Double
issues : Array[PlanIssue]
} derive(Eq, Debug)
///|
pub fn rain_event(name : String, depth_mm : Double) -> RainEvent {
{ name, depth_mm }
}
///|
pub fn catchment(
name : String,
area_m2 : Double,
runoff_coefficient : Double,
) -> Catchment {
{ name, area_m2, runoff_coefficient }
}
///|
pub fn catchment_from_surface(
name : String,
area_m2 : Double,
kind : SurfaceKind,
) -> Catchment {
catchment(name, area_m2, runoff_coefficient(kind))
}
///|
pub fn runoff_coefficient(kind : SurfaceKind) -> Double {
match kind {
Roof => 0.95
Pavement => 0.85
CompactedSoil => 0.55
Lawn => 0.30
Meadow => 0.15
}
}
///|
pub fn default_garden_spec() -> GardenSpec {
{
ponding_depth_mm: 150.0,
media_depth_mm: 450.0,
media_porosity: 0.25,
infiltration_rate_mm_per_hr: 25.0,
safety_factor: 1.20,
target_drawdown_hours: 24.0,
target_capture_ratio: 0.90,
max_cell_area_m2: 30.0,
}
}
///|
pub fn effective_storage_depth_mm(
spec : GardenSpec,
) -> Result[Double, SpongeError] {
match validate_spec(spec) {
Some(error) => Err(error)
None =>
Ok(spec.ponding_depth_mm + spec.media_depth_mm * spec.media_porosity)
}
}
///|
pub fn runoff_volume(
catchment : Catchment,
event : RainEvent,
) -> Result[RunoffResult, SpongeError] {
match validate_catchment(catchment) {
Some(error) => Err(error)
None =>
if event.depth_mm < 0.0 {
Err(NegativeRainfall)
} else {
let volume_m3 = catchment.area_m2 *
event.depth_mm /
1000.0 *
catchment.runoff_coefficient
Ok({
depth_mm: event.depth_mm * catchment.runoff_coefficient,
volume_m3,
volume_liters: volume_m3 * 1000.0,
})
}
}
}
///|
pub fn total_runoff_volume(
catchments : Array[Catchment],
event : RainEvent,
) -> Result[RunoffResult, SpongeError] {
if event.depth_mm < 0.0 {
return Err(NegativeRainfall)
}
let mut area = 0.0
let mut volume = 0.0
for catchment in catchments {
match validate_catchment(catchment) {
Some(error) => return Err(error)
None => {
area = area + catchment.area_m2
volume = volume +
catchment.area_m2 *
event.depth_mm /
1000.0 *
catchment.runoff_coefficient
}
}
}
let depth = if area == 0.0 { 0.0 } else { volume / area * 1000.0 }
Ok({ depth_mm: depth, volume_m3: volume, volume_liters: volume * 1000.0 })
}
///|
pub fn curve_number_runoff_depth(
rainfall_mm : Double,
curve_number : Double,
) -> Result[Double, SpongeError] {
match validate_curve_number_input(rainfall_mm, curve_number) {
Some(error) => Err(error)
None => {
let retention = 25400.0 / curve_number - 254.0
let abstraction = 0.2 * retention
if rainfall_mm <= abstraction {
Ok(0.0)
} else {
let excess = rainfall_mm - abstraction
Ok(excess * excess / (excess + retention))
}
}
}
}
///|
pub fn curve_number_runoff(
area_m2 : Double,
rainfall_mm : Double,
curve_number : Double,
) -> Result[CurveNumberResult, SpongeError] {
if area_m2 <= 0.0 {
return Err(NonPositiveArea)
}
match curve_number_runoff_depth(rainfall_mm, curve_number) {
Err(error) => Err(error)
Ok(depth) => {
let retention = 25400.0 / curve_number - 254.0
Ok({
rainfall_mm,
curve_number,
retention_mm: retention,
initial_abstraction_mm: retention * 0.2,
runoff_depth_mm: depth,
runoff_volume_m3: area_m2 * depth / 1000.0,
})
}
}
}
///|
pub fn size_rain_garden(
catchments : Array[Catchment],
event : RainEvent,
spec : GardenSpec,
) -> Result[GardenPlan, SpongeError] {
match validate_spec(spec) {
Some(error) => Err(error)
None =>
match total_runoff_volume(catchments, event) {
Err(error) => Err(error)
Ok(runoff) => {
let storage_depth = spec.ponding_depth_mm +
spec.media_depth_mm * spec.media_porosity
let required_area = if storage_depth == 0.0 {
0.0
} else {
runoff.volume_m3 / (storage_depth / 1000.0)
}
let recommended_area = required_area * spec.safety_factor
let cell_count = if recommended_area <= 0.0 {
0
} else {
ceil_to_int(recommended_area / spec.max_cell_area_m2)
}
let cell_area = if cell_count == 0 {
0.0
} else {
recommended_area / cell_count.to_double()
}
let drawdown = storage_depth / spec.infiltration_rate_mm_per_hr
let capture = if runoff.volume_m3 <= 0.0 {
1.0
} else {
min_double(
1.0,
recommended_area * (storage_depth / 1000.0) / runoff.volume_m3,
)
}
let issues = plan_issues(
runoff.volume_m3,
drawdown,
capture,
cell_area,
spec,
)
let status = status_for_issues(issues)
Ok({
status,
event_name: event.name,
catchment_count: catchments.length(),
catchment_area_m2: total_area(catchments),
design_rain_mm: event.depth_mm,
runoff_volume_m3: runoff.volume_m3,
storage_depth_mm: storage_depth,
required_area_m2: required_area,
recommended_area_m2: recommended_area,
cell_count,
cell_area_m2: cell_area,
drawdown_hours: drawdown,
capture_ratio: capture,
issues,
})
}
}
}
}
///|
pub fn GardenPlan::to_json(self : GardenPlan) -> String {
let out = StringBuilder::new()
out.write_string("{")
out.write_string("\"status\":\"" + self.status.label() + "\",")
out.write_string("\"event\":\"" + escape_json(self.event_name) + "\",")
out.write_string(
"\"catchment_count\":" + self.catchment_count.to_string() + ",",
)
out.write_string(
"\"catchment_area_m2\":" + self.catchment_area_m2.to_string() + ",",
)
out.write_string(
"\"design_rain_mm\":" + self.design_rain_mm.to_string() + ",",
)
out.write_string(
"\"runoff_volume_m3\":" + self.runoff_volume_m3.to_string() + ",",
)
out.write_string(
"\"storage_depth_mm\":" + self.storage_depth_mm.to_string() + ",",
)
out.write_string(
"\"recommended_area_m2\":" + self.recommended_area_m2.to_string() + ",",
)
out.write_string("\"cell_count\":" + self.cell_count.to_string() + ",")
out.write_string("\"cell_area_m2\":" + self.cell_area_m2.to_string() + ",")
out.write_string(
"\"drawdown_hours\":" + self.drawdown_hours.to_string() + ",",
)
out.write_string("\"capture_ratio\":" + self.capture_ratio.to_string() + ",")
out.write_string("\"issues\":[")
for index, issue in self.issues {
if index > 0 {
out.write_string(",")
}
out.write_string(plan_issue_to_json(issue))
}
out.write_string("]}")
out.to_string()
}
///|
pub fn GardenPlan::to_markdown(self : GardenPlan) -> String {
let out = StringBuilder::new()
out.write_string("# SpongeKit Design Card\n\n")
out.write_string("- Status: " + self.status.label() + "\n")
out.write_string("- Event: " + self.event_name + "\n")
out.write_string(
"- Catchment area: " + self.catchment_area_m2.to_string() + " m2\n",
)
out.write_string(
"- Runoff volume: " + self.runoff_volume_m3.to_string() + " m3\n",
)
out.write_string(
"- Recommended rain garden area: " +
self.recommended_area_m2.to_string() +
" m2\n",
)
out.write_string(
"- Cells: " +
self.cell_count.to_string() +
" x " +
self.cell_area_m2.to_string() +
" m2\n",
)
out.write_string("- Drawdown: " + self.drawdown_hours.to_string() + " h\n")
if self.issues.length() > 0 {
out.write_string("\n## Review Notes\n\n")
for issue in self.issues {
out.write_string("- " + issue.kind.label() + ": " + issue.message + "\n")
}
}
out.to_string()
}
///|
pub fn PlanStatus::label(self : PlanStatus) -> String {
match self {
Ready => "ready"
NeedsReview => "needs_review"
Invalid => "invalid"
}
}
///|
pub fn SpongeError::label(self : SpongeError) -> String {
match self {
NonPositiveArea => "non_positive_area"
NegativeRainfall => "negative_rainfall"
InvalidRunoffCoefficient => "invalid_runoff_coefficient"
InvalidCurveNumber => "invalid_curve_number"
InvalidInfiltrationRate => "invalid_infiltration_rate"
InvalidStorageDepth => "invalid_storage_depth"
InvalidSafetyFactor => "invalid_safety_factor"
InvalidCellLimit => "invalid_cell_limit"
InvalidWeight => "invalid_weight"
InvalidCandidateArea => "invalid_candidate_area"
InvalidSedimentLoad => "invalid_sediment_load"
InvalidFacilitySpec => "invalid_facility_spec"
InvalidCostProfile => "invalid_cost_profile"
InvalidScenarioInput => "invalid_scenario_input"
}
}
///|
pub fn PlanIssueKind::label(self : PlanIssueKind) -> String {
match self {
DrawdownTooSlow => "drawdown_too_slow"
CaptureBelowTarget => "capture_below_target"
OversizedCell => "oversized_cell"
NoRunoff => "no_runoff"
}
}
///|
fn validate_catchment(catchment : Catchment) -> SpongeError? {
if catchment.area_m2 <= 0.0 {
Some(NonPositiveArea)
} else if catchment.runoff_coefficient < 0.0 ||
catchment.runoff_coefficient > 1.0 {
Some(InvalidRunoffCoefficient)
} else {
None
}
}
///|
fn validate_curve_number_input(
rainfall_mm : Double,
curve_number : Double,
) -> SpongeError? {
if rainfall_mm < 0.0 {
Some(NegativeRainfall)
} else if curve_number < 30.0 || curve_number > 100.0 {
Some(InvalidCurveNumber)
} else {
None
}
}
///|
fn validate_spec(spec : GardenSpec) -> SpongeError? {
if spec.ponding_depth_mm < 0.0 ||
spec.media_depth_mm < 0.0 ||
spec.media_porosity < 0.0 ||
spec.media_porosity > 0.8 {
Some(InvalidStorageDepth)
} else if spec.ponding_depth_mm + spec.media_depth_mm * spec.media_porosity <=
0.0 {
Some(InvalidStorageDepth)
} else if spec.infiltration_rate_mm_per_hr <= 0.0 {
Some(InvalidInfiltrationRate)
} else if spec.safety_factor < 1.0 {
Some(InvalidSafetyFactor)
} else if spec.max_cell_area_m2 <= 0.0 {
Some(InvalidCellLimit)
} else {
None
}
}
///|
fn plan_issues(
runoff_volume : Double,
drawdown_hours : Double,
capture_ratio : Double,
cell_area : Double,
spec : GardenSpec,
) -> Array[PlanIssue] {
let issues : Array[PlanIssue] = []
if runoff_volume == 0.0 {
issues.push(issue(NoRunoff, "design event creates no runoff"))
}
if drawdown_hours > spec.target_drawdown_hours {
issues.push(
issue(
DrawdownTooSlow,
"storage drawdown is slower than the target window",
),
)
}
if capture_ratio < spec.target_capture_ratio {
issues.push(
issue(
CaptureBelowTarget,
"recommended area does not meet the target capture ratio",
),
)
}
if cell_area > spec.max_cell_area_m2 {
issues.push(issue(OversizedCell, "single cell area exceeds the cell limit"))
}
issues
}
///|
fn status_for_issues(issues : Array[PlanIssue]) -> PlanStatus {
if issues.length() == 0 {
Ready
} else {
NeedsReview
}
}
///|
fn issue(kind : PlanIssueKind, message : String) -> PlanIssue {
{ kind, message }
}
///|
fn plan_issue_to_json(issue : PlanIssue) -> String {
"{\"kind\":\"" +
issue.kind.label() +
"\",\"message\":\"" +
escape_json(issue.message) +
"\"}"
}
///|
fn total_area(catchments : Array[Catchment]) -> Double {
let mut area = 0.0
for catchment in catchments {
area = area + catchment.area_m2
}
area
}
///|
fn ceil_to_int(value : Double) -> Int {
let mut n = 0
while n.to_double() < value {
n = n + 1
}
n
}
///|
fn min_double(left : Double, right : Double) -> Double {
if left < right {
left
} else {
right
}
}
///|
fn escape_json(input : String) -> String {
let out = StringBuilder::new()
for ch in input {
match ch {
'"' => out.write_string("\\\"")
'\\' => out.write_string("\\\\")
'\n' => out.write_string("\\n")
'\r' => out.write_string("\\r")
'\t' => out.write_string("\\t")
_ => out.write_char(ch)
}
}
out.to_string()
}