///|
pub(all) struct AreaCandidate {
name : String
area_m2 : Double
max_cell_area_m2 : Double
} derive(Eq, Debug)
///|
pub(all) struct CandidatePerformance {
name : String
area_m2 : Double
cell_count : Int
cell_area_m2 : Double
storage_capacity_m3 : Double
runoff_volume_m3 : Double
captured_volume_m3 : Double
bypass_volume_m3 : Double
capture_ratio : Double
drawdown_hours : Double
score : Int
status : PlanStatus
issues : Array[PlanIssue]
} derive(Eq, Debug)
///|
pub(all) struct CandidateComparison {
candidate_count : Int
best_name : String
best_score : Int
performances : Array[CandidatePerformance]
} derive(Eq, Debug)
///|
pub fn area_candidate(name : String, area_m2 : Double) -> AreaCandidate {
{ name, area_m2, max_cell_area_m2: 30.0 }
}
///|
pub fn area_candidate_with_cell_limit(
name : String,
area_m2 : Double,
max_cell_area_m2 : Double,
) -> AreaCandidate {
{ name, area_m2, max_cell_area_m2 }
}
///|
pub fn evaluate_candidate_area(
catchments : Array[Catchment],
event : RainEvent,
spec : GardenSpec,
candidate : AreaCandidate,
) -> Result[CandidatePerformance, SpongeError] {
if candidate.area_m2 <= 0.0 || candidate.max_cell_area_m2 <= 0.0 {
return Err(InvalidCandidateArea)
}
match total_runoff_volume(catchments, event) {
Err(error) => Err(error)
Ok(runoff) =>
match effective_storage_depth_mm(spec) {
Err(error) => Err(error)
Ok(storage_depth) => {
let capacity = candidate.area_m2 * storage_depth / 1000.0
let captured = min_double(capacity, runoff.volume_m3)
let bypass = if runoff.volume_m3 <= capacity {
0.0
} else {
runoff.volume_m3 - capacity
}
let capture = if runoff.volume_m3 == 0.0 {
1.0
} else {
captured / runoff.volume_m3
}
let cell_count = ceil_to_int(
candidate.area_m2 / candidate.max_cell_area_m2,
)
let cell_area = candidate.area_m2 / cell_count.to_double()
let drawdown = storage_depth / spec.infiltration_rate_mm_per_hr
let issues = candidate_issues(
runoff.volume_m3,
capture,
cell_area,
drawdown,
spec,
)
let status = status_for_issues(issues)
let score = candidate_score(capture, bypass, drawdown, issues)
Ok({
name: candidate.name,
area_m2: candidate.area_m2,
cell_count,
cell_area_m2: cell_area,
storage_capacity_m3: capacity,
runoff_volume_m3: runoff.volume_m3,
captured_volume_m3: captured,
bypass_volume_m3: bypass,
capture_ratio: capture,
drawdown_hours: drawdown,
score,
status,
issues,
})
}
}
}
}
///|
pub fn compare_candidate_areas(
catchments : Array[Catchment],
event : RainEvent,
spec : GardenSpec,
candidates : Array[AreaCandidate],
) -> Result[CandidateComparison, SpongeError] {
if candidates.length() == 0 {
return Err(InvalidCandidateArea)
}
let performances : Array[CandidatePerformance] = []
let mut best_name = ""
let mut best_score = -1
for candidate in candidates {
match evaluate_candidate_area(catchments, event, spec, candidate) {
Err(error) => return Err(error)
Ok(performance) => {
if performance.score > best_score {
best_name = performance.name
best_score = performance.score
}
performances.push(performance)
}
}
}
Ok({
candidate_count: candidates.length(),
best_name,
best_score,
performances,
})
}
///|
pub fn CandidatePerformance::to_json(self : CandidatePerformance) -> String {
let out = StringBuilder::new()
out.write_string("{")
out.write_string("\"name\":\"" + escape_json(self.name) + "\",")
out.write_string("\"area_m2\":" + self.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(
"\"storage_capacity_m3\":" + self.storage_capacity_m3.to_string() + ",",
)
out.write_string(
"\"runoff_volume_m3\":" + self.runoff_volume_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("\"score\":" + self.score.to_string() + ",")
out.write_string("\"status\":\"" + self.status.label() + "\",")
out.write_string("\"issues\":[")
for index, issue in self.issues {
if index > 0 {
out.write_string(",")
}
out.write_string(candidate_issue_to_json(issue))
}
out.write_string("]}")
out.to_string()
}
///|
pub fn CandidateComparison::to_json(self : CandidateComparison) -> String {
let out = StringBuilder::new()
out.write_string("{")
out.write_string(
"\"candidate_count\":" + self.candidate_count.to_string() + ",",
)
out.write_string("\"best_name\":\"" + escape_json(self.best_name) + "\",")
out.write_string("\"best_score\":" + self.best_score.to_string() + ",")
out.write_string("\"performances\":[")
for index, performance in self.performances {
if index > 0 {
out.write_string(",")
}
out.write_string(performance.to_json())
}
out.write_string("]}")
out.to_string()
}
///|
pub fn CandidateComparison::to_markdown(self : CandidateComparison) -> String {
let out = StringBuilder::new()
out.write_string("# SpongeKit Candidate Comparison\n\n")
out.write_string("- Candidates: " + self.candidate_count.to_string() + "\n")
out.write_string("- Best: " + self.best_name + "\n")
out.write_string("- Best score: " + self.best_score.to_string() + "\n\n")
out.write_string(
"| Candidate | Area m2 | Capture | Bypass m3 | Score | Status |\n",
)
out.write_string("| --- | ---: | ---: | ---: | ---: | --- |\n")
for performance in self.performances {
out.write_string(
"| " +
performance.name +
" | " +
performance.area_m2.to_string() +
" | " +
performance.capture_ratio.to_string() +
" | " +
performance.bypass_volume_m3.to_string() +
" | " +
performance.score.to_string() +
" | " +
performance.status.label() +
" |\n",
)
}
out.to_string()
}
///|
fn candidate_issues(
runoff_volume : Double,
capture_ratio : Double,
cell_area : Double,
drawdown_hours : Double,
spec : GardenSpec,
) -> Array[PlanIssue] {
let issues : Array[PlanIssue] = []
if runoff_volume == 0.0 {
issues.push(
issue(NoRunoff, "candidate receives no runoff under this event"),
)
}
if capture_ratio < spec.target_capture_ratio {
issues.push(
issue(
CaptureBelowTarget,
"candidate area captures less than the target ratio",
),
)
}
if drawdown_hours > spec.target_drawdown_hours {
issues.push(
issue(DrawdownTooSlow, "candidate drawdown exceeds the target window"),
)
}
if cell_area > spec.max_cell_area_m2 {
issues.push(
issue(OversizedCell, "candidate cell split exceeds the cell limit"),
)
}
issues
}
///|
fn candidate_score(
capture_ratio : Double,
bypass_volume : Double,
drawdown_hours : Double,
issues : Array[PlanIssue],
) -> Int {
let capture_points = (clamp_double(capture_ratio, 0.0, 1.0) * 80.0).to_int()
let bypass_penalty = if bypass_volume > 5.0 {
12
} else if bypass_volume > 1.0 {
6
} else if bypass_volume > 0.0 {
3
} else {
0
}
let drawdown_bonus = if drawdown_hours <= 12.0 {
15
} else if drawdown_hours <= 24.0 {
10
} else {
0
}
let issue_penalty = issues.length() * 5
clamp_int(
capture_points + drawdown_bonus + 5 - bypass_penalty - issue_penalty,
0,
100,
)
}
///|
fn candidate_issue_to_json(issue : PlanIssue) -> String {
"{\"kind\":\"" +
issue.kind.label() +
"\",\"message\":\"" +
escape_json(issue.message) +
"\"}"
}