forked from 1b-agent/opencadstudio-upstream
Add plugin host architecture and Storm Sewer add-on package
Generic plugin runtime (src/plugin/, HostSession, per-tab plugin_state, command router). QGIS-style add-on layout with plugin.toml and single PluginRegistration. Storm Sewer refactored as reference consumer with dispatch.rs, catchments, LandXML, headless tests (43 passing). Core update.rs decoupled from storm_sewer via CadCommand object-pick hooks. Docs: plugin-architecture.md, plugin-template, PR and issue-78 reply drafts.
This commit is contained in:
1 parent
1199895f0e
commit
e46701456f
66 files changed
+4538
-141
No files matched your search
@@ -10,6 +10,7 @@ categories = ["science", "simulation"]
|
||||
|
||||
# Engine is std-only on purpose: dependency-light and WASM-friendly.
|
||||
[dependencies]
|
||||
quick-xml = { version = "0.37", default-features = false }
|
||||
|
||||
[lib]
|
||||
name = "stormsewer"
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LandXML xmlns="http://www.landxml.org/schema/LandXML-1.2" version="1.2" date="2026-06-09">
|
||||
<Units>
|
||||
<Imperial areaUnit="squareFoot" linearUnit="foot" diameterUnit="inch"/>
|
||||
</Units>
|
||||
<PipeNetworks>
|
||||
<PipeNetwork name="Sample">
|
||||
<Structs>
|
||||
<Struct name="IN1" role="inlet">
|
||||
<Center>0.0 0.0 110.0</Center>
|
||||
<Invert>104.0</Invert>
|
||||
<ElevRim>110.0</ElevRim>
|
||||
</Struct>
|
||||
<Struct name="J1" role="junction">
|
||||
<Center>150.0 0.0 108.0</Center>
|
||||
<Invert>102.0</Invert>
|
||||
<ElevRim>108.0</ElevRim>
|
||||
</Struct>
|
||||
<Struct name="OUT1" role="outfall">
|
||||
<Center>300.0 0.0 106.0</Center>
|
||||
<Invert>100.0</Invert>
|
||||
<ElevRim>106.0</ElevRim>
|
||||
</Struct>
|
||||
</Structs>
|
||||
<Pipes>
|
||||
<Pipe name="P1">
|
||||
<CircPipe diameter="15"/>
|
||||
<StartStruct>IN1</StartStruct>
|
||||
<EndStruct>J1</EndStruct>
|
||||
</Pipe>
|
||||
<Pipe name="P2">
|
||||
<CircPipe diameter="18"/>
|
||||
<StartStruct>J1</StartStruct>
|
||||
<EndStruct>OUT1</EndStruct>
|
||||
</Pipe>
|
||||
</Pipes>
|
||||
</PipeNetwork>
|
||||
</PipeNetworks>
|
||||
</LandXML>
|
||||
@@ -0,0 +1,84 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Catchment polygon geometry helpers (CAD-agnostic).
|
||||
|
||||
use crate::hydrology::kirpich_minutes;
|
||||
|
||||
/// Shoelace formula area for a closed polygon (square feet).
|
||||
pub fn shoelace_area_sqft(vertices: &[(f64, f64)]) -> f64 {
|
||||
let n = vertices.len();
|
||||
if n < 3 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sum = 0.0;
|
||||
for i in 0..n {
|
||||
let (x0, y0) = vertices[i];
|
||||
let (x1, y1) = vertices[(i + 1) % n];
|
||||
sum += x0 * y1 - x1 * y0;
|
||||
}
|
||||
(sum / 2.0).abs()
|
||||
}
|
||||
|
||||
/// Plan centroid of a closed polygon.
|
||||
pub fn polygon_centroid(vertices: &[(f64, f64)]) -> (f64, f64) {
|
||||
let n = vertices.len();
|
||||
if n < 3 {
|
||||
let sx: f64 = vertices.iter().map(|v| v.0).sum();
|
||||
let sy: f64 = vertices.iter().map(|v| v.1).sum();
|
||||
let d = n.max(1) as f64;
|
||||
return (sx / d, sy / d);
|
||||
}
|
||||
let mut a2 = 0.0;
|
||||
let mut cx = 0.0;
|
||||
let mut cy = 0.0;
|
||||
for i in 0..n {
|
||||
let (x0, y0) = vertices[i];
|
||||
let (x1, y1) = vertices[(i + 1) % n];
|
||||
let cross = x0 * y1 - x1 * y0;
|
||||
a2 += cross;
|
||||
cx += (x0 + x1) * cross;
|
||||
cy += (y0 + y1) * cross;
|
||||
}
|
||||
if a2.abs() < 1e-12 {
|
||||
let sx: f64 = vertices.iter().map(|v| v.0).sum();
|
||||
let sy: f64 = vertices.iter().map(|v| v.1).sum();
|
||||
return (sx / n as f64, sy / n as f64);
|
||||
}
|
||||
(cx / (3.0 * a2), cy / (3.0 * a2))
|
||||
}
|
||||
|
||||
/// Convert square feet to acres.
|
||||
pub fn sqft_to_acres(area_sqft: f64) -> f64 {
|
||||
area_sqft / 43_560.0
|
||||
}
|
||||
|
||||
/// Default flow-path length: plan distance from catchment centroid to a target point (ft).
|
||||
pub fn default_flow_length_ft(centroid: (f64, f64), target: (f64, f64)) -> f64 {
|
||||
let dx = target.0 - centroid.0;
|
||||
let dy = target.1 - centroid.1;
|
||||
(dx * dx + dy * dy).sqrt()
|
||||
}
|
||||
|
||||
/// Kirpich Tc (minutes) for a catchment polygon draining toward a structure.
|
||||
pub fn catchment_tc_minutes(flow_length_ft: f64, slope: f64) -> f64 {
|
||||
kirpich_minutes(flow_length_ft, slope)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unit_square_area() {
|
||||
let verts = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
|
||||
assert!((shoelace_area_sqft(&verts) - 100.0).abs() < 1e-6);
|
||||
assert!((sqft_to_acres(43_560.0) - 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centroid_of_square() {
|
||||
let verts = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
|
||||
let (cx, cy) = polygon_centroid(&verts);
|
||||
assert!((cx - 5.0).abs() < 1e-6 && (cy - 5.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Design criteria for storm-sewer pipe sizing (velocity, capacity, catalogs).
|
||||
|
||||
/// Standard reinforced-concrete pipe diameters (inches) used in US storm design.
|
||||
pub const STANDARD_RCP_INCHES: &[u32] =
|
||||
&[8, 10, 12, 15, 18, 21, 24, 27, 30, 33, 36, 42, 48, 54, 60, 66, 72];
|
||||
|
||||
/// Convert a catalog diameter from inches to feet.
|
||||
pub fn inches_to_ft(d_in: u32) -> f64 {
|
||||
d_in as f64 / 12.0
|
||||
}
|
||||
|
||||
/// Default ascending catalog in feet.
|
||||
pub fn standard_diameters_ft() -> Vec<f64> {
|
||||
STANDARD_RCP_INCHES.iter().map(|&d| inches_to_ft(d)).collect()
|
||||
}
|
||||
|
||||
/// Agency-style limits used by [`super::sizing::size_pipe_for_flow`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DesignCriteria {
|
||||
/// Minimum design velocity (ft/s). Pipes below this are rejected.
|
||||
pub min_velocity: f64,
|
||||
/// Maximum design velocity (ft/s). Pipes above this are rejected.
|
||||
pub max_velocity: f64,
|
||||
/// Maximum design flow as a fraction of just-full Manning capacity.
|
||||
pub max_pct_full: f64,
|
||||
/// Ascending catalog of trial diameters (ft).
|
||||
pub standard_diameters_ft: Vec<f64>,
|
||||
/// When true, reject diameters where design Q exceeds open-channel capacity.
|
||||
pub require_open_channel: bool,
|
||||
}
|
||||
|
||||
impl Default for DesignCriteria {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_velocity: 2.0,
|
||||
max_velocity: 10.0,
|
||||
max_pct_full: 0.85,
|
||||
standard_diameters_ft: standard_diameters_ft(),
|
||||
require_open_channel: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DesignCriteria {
|
||||
/// Typical municipal / DOT storm trunk defaults.
|
||||
pub fn municipal() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Slightly relaxed criteria for laterals (allows higher % full).
|
||||
pub fn lateral() -> Self {
|
||||
Self { max_pct_full: 0.95, ..Self::default() }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn catalog_is_ascending_in_feet() {
|
||||
let d = standard_diameters_ft();
|
||||
assert!(d.windows(2).all(|w| w[0] < w[1]));
|
||||
assert!((d[0] - inches_to_ft(8)).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Inlet interception capacity (HEC-22 style grate on grade, simplified).
|
||||
|
||||
/// Grate-on-grade interception capacity (cfs).
|
||||
///
|
||||
/// Uses the HEC-22 composite gutter approximation for a depressed grate:
|
||||
/// `Q = Cw * L * d^1.5 * sqrt(S)` with `Cw ≈ 3.0` (US customary calibration).
|
||||
pub fn grate_capacity_cfs(grate_length_ft: f64, flow_depth_ft: f64, gutter_slope: f64) -> f64 {
|
||||
if grate_length_ft <= 0.0 || flow_depth_ft <= 0.0 || gutter_slope <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
const CW: f64 = 3.0;
|
||||
CW * grate_length_ft * flow_depth_ft.powf(1.5) * gutter_slope.sqrt()
|
||||
}
|
||||
|
||||
/// Check whether an inlet can capture the approach design flow.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct InletCheck {
|
||||
pub design_q_cfs: f64,
|
||||
pub capacity_cfs: f64,
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
pub fn check_inlet(design_q_cfs: f64, grate_length_ft: f64, flow_depth_ft: f64, gutter_slope: f64) -> InletCheck {
|
||||
let cap = grate_capacity_cfs(grate_length_ft, flow_depth_ft, gutter_slope);
|
||||
InletCheck {
|
||||
design_q_cfs,
|
||||
capacity_cfs: cap,
|
||||
ok: cap >= design_q_cfs,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn longer_grate_carries_more() {
|
||||
let a = grate_capacity_cfs(2.0, 0.15, 0.005);
|
||||
let b = grate_capacity_cfs(5.0, 0.15, 0.005);
|
||||
assert!(b > a);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Storm-sewer design: criteria catalogs, pipe sizing, and sizing reports.
|
||||
|
||||
pub mod criteria;
|
||||
pub mod inlets;
|
||||
pub mod sizing;
|
||||
|
||||
pub use criteria::*;
|
||||
pub use inlets::*;
|
||||
pub use sizing::*;
|
||||
@@ -0,0 +1,287 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Pipe sizing against design criteria — smallest standard pipe that carries
|
||||
//! the Rational design flow within velocity and capacity limits.
|
||||
|
||||
use crate::hydraulics::{
|
||||
circular_geometry, full_area, full_flow_capacity, max_capacity, normal_depth, K_MANNING_US,
|
||||
};
|
||||
use crate::network::{Analysis, Network, Pipe};
|
||||
|
||||
use super::criteria::DesignCriteria;
|
||||
|
||||
/// Outcome of sizing a single pipe for a known discharge and slope.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SizeOutcome {
|
||||
/// Smallest catalog pipe that meets all criteria.
|
||||
Sized,
|
||||
/// Current diameter already meets criteria (may equal recommended).
|
||||
Adequate,
|
||||
/// No catalog pipe satisfies the criteria at this slope.
|
||||
NoSolution,
|
||||
}
|
||||
|
||||
/// Result of sizing one pipe cross-section.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PipeSizeResult {
|
||||
pub diameter_ft: f64,
|
||||
pub velocity: f64,
|
||||
pub pct_full: f64,
|
||||
pub normal_depth: Option<f64>,
|
||||
pub surcharged: bool,
|
||||
pub outcome: SizeOutcome,
|
||||
}
|
||||
|
||||
/// Per-pipe recommendation for a sized network.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PipeSizeRecommendation {
|
||||
pub pipe_id: String,
|
||||
pub design_q: f64,
|
||||
pub slope: f64,
|
||||
pub current_diameter_ft: f64,
|
||||
pub recommended_diameter_ft: f64,
|
||||
pub meets_criteria: bool,
|
||||
pub velocity: f64,
|
||||
pub pct_full: f64,
|
||||
pub surcharged: bool,
|
||||
pub outcome: SizeOutcome,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
/// Evaluate whether diameter `d` carries `q` on slope `s` within criteria.
|
||||
fn evaluate_diameter(q: f64, slope: f64, n: f64, d: f64, criteria: &DesignCriteria) -> Option<PipeSizeResult> {
|
||||
if d <= 0.0 || q < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let k = K_MANNING_US;
|
||||
let (q_max, _) = max_capacity(n, slope, d, k);
|
||||
if criteria.require_open_channel && q > q_max + 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let yn = normal_depth(q, n, slope, d, k);
|
||||
let surcharged = yn.is_none();
|
||||
if criteria.require_open_channel && surcharged {
|
||||
return None;
|
||||
}
|
||||
let area = if surcharged {
|
||||
full_area(d)
|
||||
} else {
|
||||
circular_geometry(yn.unwrap_or(0.0), d).0
|
||||
};
|
||||
let velocity = if area > 0.0 { q / area } else { 0.0 };
|
||||
let capacity = full_flow_capacity(n, slope, d, k);
|
||||
let pct_full = if capacity > 0.0 { q / capacity } else { 0.0 };
|
||||
|
||||
if velocity < criteria.min_velocity - 1e-9 {
|
||||
return None;
|
||||
}
|
||||
if velocity > criteria.max_velocity + 1e-9 {
|
||||
return None;
|
||||
}
|
||||
if pct_full > criteria.max_pct_full + 1e-9 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(PipeSizeResult {
|
||||
diameter_ft: d,
|
||||
velocity,
|
||||
pct_full,
|
||||
normal_depth: yn,
|
||||
surcharged,
|
||||
outcome: SizeOutcome::Sized,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pick the smallest catalog diameter that carries `q` on slope `s`.
|
||||
pub fn size_pipe_for_flow(q: f64, slope: f64, n: f64, criteria: &DesignCriteria) -> PipeSizeResult {
|
||||
for &d in &criteria.standard_diameters_ft {
|
||||
if let Some(r) = evaluate_diameter(q, slope, n, d, criteria) {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
// No solution — report the largest catalog pipe's hydraulics for diagnostics.
|
||||
let d = *criteria.standard_diameters_ft.last().unwrap_or(&0.0);
|
||||
let k = K_MANNING_US;
|
||||
let yn = normal_depth(q, n, slope, d, k);
|
||||
let surcharged = yn.is_none();
|
||||
let area = if surcharged {
|
||||
full_area(d)
|
||||
} else {
|
||||
circular_geometry(yn.unwrap_or(0.0), d).0
|
||||
};
|
||||
let velocity = if area > 0.0 { q / area } else { 0.0 };
|
||||
let capacity = full_flow_capacity(n, slope, d, k);
|
||||
PipeSizeResult {
|
||||
diameter_ft: d,
|
||||
velocity,
|
||||
pct_full: if capacity > 0.0 { q / capacity } else { 0.0 },
|
||||
normal_depth: yn,
|
||||
surcharged,
|
||||
outcome: SizeOutcome::NoSolution,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether an existing diameter meets criteria (no upsizing).
|
||||
pub fn check_pipe(q: f64, slope: f64, n: f64, diameter_ft: f64, criteria: &DesignCriteria) -> PipeSizeResult {
|
||||
if let Some(mut r) = evaluate_diameter(q, slope, n, diameter_ft, criteria) {
|
||||
r.outcome = SizeOutcome::Adequate;
|
||||
r
|
||||
} else {
|
||||
size_pipe_for_flow(q, slope, n, criteria)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_diameter_in(d_ft: f64) -> String {
|
||||
let inches = (d_ft * 12.0).round() as i32;
|
||||
format!("{inches}\"")
|
||||
}
|
||||
|
||||
fn recommend_for_pipe(p: &Pipe, design_q: f64, slope: f64, criteria: &DesignCriteria) -> PipeSizeRecommendation {
|
||||
let current = p.diameter;
|
||||
let check = check_pipe(design_q, slope, p.n, current, criteria);
|
||||
let sized = size_pipe_for_flow(design_q, slope, p.n, criteria);
|
||||
|
||||
let meets = check.outcome == SizeOutcome::Adequate
|
||||
&& (check.diameter_ft - current).abs() < 1e-6
|
||||
&& !check.surcharged;
|
||||
|
||||
let (recommended, outcome, note) = if meets {
|
||||
(
|
||||
current,
|
||||
SizeOutcome::Adequate,
|
||||
format!("{} meets criteria ({:.1}% full, {:.2} ft/s)", p.id, check.pct_full * 100.0, check.velocity),
|
||||
)
|
||||
} else if sized.outcome == SizeOutcome::NoSolution {
|
||||
(
|
||||
sized.diameter_ft,
|
||||
SizeOutcome::NoSolution,
|
||||
format!(
|
||||
"{}: no catalog pipe meets criteria (Q={:.2} cfs, S={:.4}); largest tried {} still surcharged={}",
|
||||
p.id,
|
||||
design_q,
|
||||
slope,
|
||||
format_diameter_in(sized.diameter_ft),
|
||||
sized.surcharged
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
sized.diameter_ft,
|
||||
SizeOutcome::Sized,
|
||||
format!(
|
||||
"{}: upsize {} → {} ({:.1}% full, {:.2} ft/s)",
|
||||
p.id,
|
||||
format_diameter_in(current),
|
||||
format_diameter_in(sized.diameter_ft),
|
||||
sized.pct_full * 100.0,
|
||||
sized.velocity
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
PipeSizeRecommendation {
|
||||
pipe_id: p.id.clone(),
|
||||
design_q,
|
||||
slope,
|
||||
current_diameter_ft: current,
|
||||
recommended_diameter_ft: recommended,
|
||||
meets_criteria: meets,
|
||||
velocity: if meets { check.velocity } else { sized.velocity },
|
||||
pct_full: if meets { check.pct_full } else { sized.pct_full },
|
||||
surcharged: if meets { check.surcharged } else { sized.surcharged },
|
||||
outcome,
|
||||
note,
|
||||
}
|
||||
}
|
||||
|
||||
/// Size every pipe in `net` using flows from a completed [`Analysis`].
|
||||
pub fn size_network(net: &Network, analysis: &Analysis, criteria: &DesignCriteria) -> Vec<PipeSizeRecommendation> {
|
||||
net.pipes
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let pr = analysis.pipes.iter().find(|r| r.id == p.id);
|
||||
let (q, slope) = pr.map(|r| (r.design_q, r.slope)).unwrap_or((0.0, 0.0));
|
||||
recommend_for_pipe(p, q, slope, criteria)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Apply recommended diameters to a network (returns a cloned, sized network).
|
||||
pub fn apply_sizing(net: &Network, recs: &[PipeSizeRecommendation]) -> Network {
|
||||
let mut sized = net.clone();
|
||||
for (p, r) in sized.pipes.iter_mut().zip(recs.iter()) {
|
||||
if r.outcome != SizeOutcome::NoSolution {
|
||||
p.diameter = r.recommended_diameter_ft;
|
||||
}
|
||||
}
|
||||
sized
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::network::{Network, Node};
|
||||
|
||||
fn heavy_net() -> Network {
|
||||
Network {
|
||||
nodes: vec![
|
||||
Node::inlet("N1", 100.0, 105.0, 2.0, 0.7),
|
||||
Node::inlet("N2", 99.0, 104.0, 3.0, 0.8),
|
||||
Node::outfall("OUT", 98.0, 103.0),
|
||||
],
|
||||
pipes: vec![
|
||||
Pipe::new("P1", "N1", "N2", 100.0, 1.5, 0.013),
|
||||
Pipe::new("P2", "N2", "OUT", 100.0, 1.5, 0.013),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adequately_sized_pipe_is_adequate() {
|
||||
let net = heavy_net();
|
||||
let pipes = net.analyze_rational(2.0).unwrap();
|
||||
let p2_q = pipes.iter().find(|x| x.id == "P2").unwrap().design_q;
|
||||
let criteria = DesignCriteria::default();
|
||||
let recs = size_network(&net, &Analysis { pipes, nodes: vec![] }, &criteria);
|
||||
let p2r = recs.iter().find(|r| r.pipe_id == "P2").unwrap();
|
||||
assert!(p2r.meets_criteria || p2r.recommended_diameter_ft >= 1.5);
|
||||
assert!(p2_q > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undersized_pipe_gets_larger_recommendation() {
|
||||
let net = heavy_net();
|
||||
let pipes = net.analyze_rational(4.0).unwrap();
|
||||
assert!(pipes.iter().find(|x| x.id == "P2").unwrap().surcharged);
|
||||
let criteria = DesignCriteria::default();
|
||||
let recs = size_network(&net, &Analysis { pipes, nodes: vec![] }, &criteria);
|
||||
let p2r = recs.iter().find(|r| r.pipe_id == "P2").unwrap();
|
||||
assert!(p2r.recommended_diameter_ft > 1.5, "got {}", p2r.recommended_diameter_ft);
|
||||
assert_eq!(p2r.outcome, SizeOutcome::Sized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_sizing_updates_diameters() {
|
||||
let net = heavy_net();
|
||||
let pipes = net.analyze_rational(4.0).unwrap();
|
||||
let criteria = DesignCriteria::default();
|
||||
let recs = size_network(&net, &Analysis { pipes, nodes: vec![] }, &criteria);
|
||||
let sized = apply_sizing(&net, &recs);
|
||||
let p2 = sized.pipes.iter().find(|p| p.id == "P2").unwrap();
|
||||
assert!(p2.diameter > 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_network_has_catalog_solutions() {
|
||||
let text = include_str!("../../examples/sample.ssn");
|
||||
let parsed = crate::parse::parse_ssn(text).unwrap();
|
||||
let a = parsed.network.analyze(&parsed.idf, &parsed.options).unwrap();
|
||||
assert!(a.pipes.iter().all(|p| !p.surcharged), "sample should not surcharge");
|
||||
let recs = size_network(&parsed.network, &a, &DesignCriteria::default());
|
||||
assert!(
|
||||
recs.iter().all(|r| r.outcome != SizeOutcome::NoSolution),
|
||||
"unexpected: {:?}",
|
||||
recs.iter().map(|r| (&r.pipe_id, &r.note)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Multi-return-period IDF curves for storm-sewer design.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::idf::IdfCurve;
|
||||
|
||||
/// Rainfall IDF curves keyed by return period (years).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct IdfSet {
|
||||
/// Active design storm return period (years).
|
||||
pub design_rp: u32,
|
||||
curves: BTreeMap<u32, IdfCurve>,
|
||||
}
|
||||
|
||||
impl Default for IdfSet {
|
||||
fn default() -> Self {
|
||||
let mut curves = BTreeMap::new();
|
||||
curves.insert(10, IdfCurve::new(60.0, 10.0, 0.8));
|
||||
Self { design_rp: 10, curves }
|
||||
}
|
||||
}
|
||||
|
||||
impl IdfSet {
|
||||
pub fn new(design_rp: u32) -> Self {
|
||||
Self { design_rp, curves: BTreeMap::new() }
|
||||
}
|
||||
|
||||
/// Municipal default: 10-year curve `i = 60/(t+10)^0.8`.
|
||||
pub fn municipal_default() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn set_curve(&mut self, rp: u32, curve: IdfCurve) {
|
||||
self.curves.insert(rp, curve);
|
||||
}
|
||||
|
||||
pub fn curve(&self, rp: u32) -> Option<&IdfCurve> {
|
||||
self.curves.get(&rp)
|
||||
}
|
||||
|
||||
pub fn design_curve(&self) -> &IdfCurve {
|
||||
self.curves
|
||||
.get(&self.design_rp)
|
||||
.or_else(|| self.curves.values().next())
|
||||
.expect("IdfSet must contain at least one curve")
|
||||
}
|
||||
|
||||
pub fn set_design_rp(&mut self, rp: u32) {
|
||||
self.design_rp = rp;
|
||||
}
|
||||
|
||||
/// Intensity (in/hr) for duration `t_min` at the design return period.
|
||||
pub fn design_intensity(&self, t_min: f64) -> f64 {
|
||||
self.design_curve().intensity(t_min)
|
||||
}
|
||||
|
||||
/// All configured return periods, ascending.
|
||||
pub fn return_periods(&self) -> Vec<u32> {
|
||||
self.curves.keys().copied().collect()
|
||||
}
|
||||
|
||||
/// Analyze intensity at every configured return period for one duration.
|
||||
pub fn intensities_at(&self, t_min: f64) -> Vec<(u32, f64)> {
|
||||
self.return_periods()
|
||||
.into_iter()
|
||||
.map(|rp| (rp, self.curves[&rp].intensity(t_min)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn design_curve_defaults_to_10yr() {
|
||||
let set = IdfSet::default();
|
||||
assert_eq!(set.design_rp, 10);
|
||||
assert!(set.design_intensity(15.0) > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_return_periods() {
|
||||
let mut set = IdfSet::default();
|
||||
set.set_curve(25, IdfCurve::new(80.0, 15.0, 0.8));
|
||||
set.set_design_rp(25);
|
||||
let i10 = set.curve(10).unwrap().intensity(20.0);
|
||||
let i25 = set.design_intensity(20.0);
|
||||
assert!(i25 > i10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
pub mod idf_set;
|
||||
pub mod tc;
|
||||
|
||||
pub use idf_set::*;
|
||||
pub use tc::*;
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Time-of-concentration estimators (minutes).
|
||||
|
||||
/// Kirpich (1940) — overland flow over unpaved channel.
|
||||
/// `l` = flow path length (ft), `s` = average slope (ft/ft, positive).
|
||||
pub fn kirpich_minutes(l: f64, s: f64) -> f64 {
|
||||
if l <= 0.0 || s <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
0.0078 * l.powf(0.77) * s.powf(-0.385)
|
||||
}
|
||||
|
||||
/// FAA / TR-55 style sheet flow on paved surfaces.
|
||||
/// `l` = flow path (ft), `s` = slope (ft/ft).
|
||||
pub fn faa_sheet_flow_minutes(l: f64, s: f64) -> f64 {
|
||||
if l <= 0.0 || s <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
// n=0.02, k=0.007 (US customary) → t = 0.007 * (n*L)^0.8 / (S^0.5 * k^0.2) with n fixed
|
||||
let n = 0.02_f64;
|
||||
let k = 0.007_f64;
|
||||
0.007_f64 * (n * l).powf(0.8) / (s.sqrt() * k.powf(0.2))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kirpich_increases_with_length() {
|
||||
let short = kirpich_minutes(200.0, 0.02);
|
||||
let long = kirpich_minutes(800.0, 0.02);
|
||||
assert!(long > short);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faa_reasonable_range() {
|
||||
let t = faa_sheet_flow_minutes(300.0, 0.01);
|
||||
assert!(t > 0.5 && t < 30.0, "t={t}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! LandXML 1.2 pipe-network import (Civil 3D / InfraModel compatible subset).
|
||||
|
||||
use crate::network::NodeKind;
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::Reader;
|
||||
/// Linear units used in the source document.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum LinearUnit {
|
||||
#[default]
|
||||
Foot,
|
||||
Meter,
|
||||
}
|
||||
|
||||
/// Diameter units for circular pipes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum DiameterUnit {
|
||||
#[default]
|
||||
Inch,
|
||||
Foot,
|
||||
Millimeter,
|
||||
Meter,
|
||||
}
|
||||
|
||||
/// Parsed LandXML document (one or more pipe networks).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LandXmlDocument {
|
||||
pub linear_unit: LinearUnit,
|
||||
pub diameter_unit: DiameterUnit,
|
||||
pub networks: Vec<LandXmlNetwork>,
|
||||
}
|
||||
|
||||
/// A single pipe network from LandXML.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LandXmlNetwork {
|
||||
pub name: String,
|
||||
pub structures: Vec<LandXmlStruct>,
|
||||
pub pipes: Vec<LandXmlPipe>,
|
||||
}
|
||||
|
||||
/// Structure (manhole / inlet / outfall) from LandXML.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LandXmlStruct {
|
||||
pub name: String,
|
||||
pub kind: NodeKind,
|
||||
/// Easting / X (ft).
|
||||
pub x: f64,
|
||||
/// Northing / Y (ft).
|
||||
pub y: f64,
|
||||
pub invert: f64,
|
||||
pub rim: f64,
|
||||
pub area_ac: f64,
|
||||
pub c: f64,
|
||||
}
|
||||
|
||||
/// Pipe link from LandXML.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LandXmlPipe {
|
||||
pub name: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
/// Internal diameter (ft).
|
||||
pub diameter_ft: f64,
|
||||
pub n: f64,
|
||||
}
|
||||
|
||||
impl LandXmlDocument {
|
||||
/// First network, or an error when the file contains none.
|
||||
pub fn primary_network(&self) -> Result<&LandXmlNetwork, String> {
|
||||
self.networks.first().ok_or_else(|| "LandXML: no <PipeNetwork> found".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a LandXML document string into structures and pipes.
|
||||
pub fn parse_landxml(xml: &str) -> Result<LandXmlDocument, String> {
|
||||
let mut reader = Reader::from_str(xml);
|
||||
reader.config_mut().trim_text(true);
|
||||
|
||||
let mut doc = LandXmlDocument::default();
|
||||
let mut buf = Vec::new();
|
||||
|
||||
let mut in_imperial = false;
|
||||
let mut in_metric = false;
|
||||
|
||||
let mut cur_network: Option<LandXmlNetwork> = None;
|
||||
let mut in_structs = false;
|
||||
let mut in_pipes = false;
|
||||
|
||||
let mut cur_struct: Option<LandXmlStruct> = None;
|
||||
let mut cur_pipe: Option<LandXmlPipe> = None;
|
||||
let mut text_buf = String::new();
|
||||
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Empty(e)) => {
|
||||
let raw = String::from_utf8_lossy(e.name().as_ref()).into_owned();
|
||||
let name = local_name(&raw);
|
||||
if name == "CircPipe" {
|
||||
if let Some(d) = attr_value(&e, "diameter").and_then(|s| s.parse::<f64>().ok()) {
|
||||
if let Some(p) = cur_pipe.as_mut() {
|
||||
p.diameter_ft = to_diameter_ft(d, doc.diameter_unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Start(e)) => {
|
||||
let raw = String::from_utf8_lossy(e.name().as_ref()).into_owned();
|
||||
let name = local_name(&raw);
|
||||
text_buf.clear();
|
||||
|
||||
match name.as_str() {
|
||||
"Imperial" => {
|
||||
in_imperial = true;
|
||||
if let Some(u) = attr_value(&e, "linearUnit") {
|
||||
doc.linear_unit = parse_linear_unit(&u);
|
||||
}
|
||||
if let Some(u) = attr_value(&e, "diameterUnit") {
|
||||
doc.diameter_unit = parse_diameter_unit(&u);
|
||||
}
|
||||
}
|
||||
"Metric" => {
|
||||
in_metric = true;
|
||||
doc.linear_unit = LinearUnit::Meter;
|
||||
}
|
||||
"PipeNetwork" => {
|
||||
let net_name = attr_value(&e, "name").unwrap_or_else(|| "Network".into());
|
||||
cur_network = Some(LandXmlNetwork { name: net_name, ..Default::default() });
|
||||
}
|
||||
"Structs" if cur_network.is_some() => in_structs = true,
|
||||
"Pipes" if cur_network.is_some() => in_pipes = true,
|
||||
"Struct" if in_structs => {
|
||||
let sname = attr_value(&e, "name")
|
||||
.or_else(|| attr_value(&e, "id"))
|
||||
.unwrap_or_else(|| format!("S{}", cur_network.as_ref().map(|n| n.structures.len()).unwrap_or(0) + 1));
|
||||
let role = attr_value(&e, "role").unwrap_or_default();
|
||||
let kind = infer_kind(&sname, &role);
|
||||
cur_struct = Some(LandXmlStruct {
|
||||
name: sname,
|
||||
kind,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
invert: 0.0,
|
||||
rim: 0.0,
|
||||
area_ac: 0.0,
|
||||
c: 0.7,
|
||||
});
|
||||
}
|
||||
"Pipe" if in_pipes => {
|
||||
let pname = attr_value(&e, "name")
|
||||
.or_else(|| attr_value(&e, "id"))
|
||||
.unwrap_or_else(|| format!("P{}", cur_network.as_ref().map(|n| n.pipes.len()).unwrap_or(0) + 1));
|
||||
cur_pipe = Some(LandXmlPipe {
|
||||
name: pname,
|
||||
from: String::new(),
|
||||
to: String::new(),
|
||||
diameter_ft: 1.0,
|
||||
n: 0.013,
|
||||
});
|
||||
}
|
||||
"CircPipe" if cur_pipe.is_some() => {
|
||||
if let Some(d) = attr_value(&e, "diameter").and_then(|s| s.parse::<f64>().ok()) {
|
||||
if let Some(p) = cur_pipe.as_mut() {
|
||||
p.diameter_ft = to_diameter_ft(d, doc.diameter_unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
"Center" if cur_struct.is_some() => {
|
||||
if let (Some(n), Some(ea)) = (attr_value(&e, "north").and_then(|s| s.parse().ok()), attr_value(&e, "east").and_then(|s| s.parse().ok())) {
|
||||
if let Some(s) = cur_struct.as_mut() {
|
||||
s.y = to_linear_ft(n, doc.linear_unit);
|
||||
s.x = to_linear_ft(ea, doc.linear_unit);
|
||||
if let Some(el) = attr_value(&e, "elev").and_then(|v| v.parse().ok()) {
|
||||
s.rim = to_linear_ft(el, doc.linear_unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Event::Text(t)) => {
|
||||
text_buf.push_str(&t.unescape().map_err(|e| e.to_string())?);
|
||||
}
|
||||
Ok(Event::End(e)) => {
|
||||
let raw = String::from_utf8_lossy(e.name().as_ref()).into_owned();
|
||||
let name = local_name(&raw);
|
||||
let text = text_buf.trim().to_string();
|
||||
|
||||
if !text.is_empty() {
|
||||
match name.as_str() {
|
||||
"linearUnit" if in_imperial => doc.linear_unit = parse_linear_unit(&text),
|
||||
"diameterUnit" if in_imperial => doc.diameter_unit = parse_diameter_unit(&text),
|
||||
"linearUnit" if in_metric => doc.linear_unit = LinearUnit::Meter,
|
||||
"diameterUnit" if in_metric => doc.diameter_unit = parse_diameter_unit(&text),
|
||||
"Center" if let Some(s) = cur_struct.as_mut() => {
|
||||
if let Some((a, b, c)) = parse_coords(&text) {
|
||||
// LandXML may be N E Z or E N Z; prefer E N when third is elevation.
|
||||
s.x = to_linear_ft(a, doc.linear_unit);
|
||||
s.y = to_linear_ft(b, doc.linear_unit);
|
||||
if c.abs() > 1e-6 {
|
||||
s.rim = to_linear_ft(c, doc.linear_unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
"Invert" | "InvertElev" if let Some(s) = cur_struct.as_mut() => {
|
||||
if let Ok(v) = text.parse::<f64>() {
|
||||
s.invert = to_linear_ft(v, doc.linear_unit);
|
||||
}
|
||||
}
|
||||
"ElevRim" | "Rim" | "RimElev" if let Some(s) = cur_struct.as_mut() => {
|
||||
if let Ok(v) = text.parse::<f64>() {
|
||||
s.rim = to_linear_ft(v, doc.linear_unit);
|
||||
}
|
||||
}
|
||||
"StartStruct" | "RefStart" | "BegStruct" if let Some(p) = cur_pipe.as_mut() => {
|
||||
p.from = text;
|
||||
}
|
||||
"EndStruct" | "RefEnd" | "EndStructRef" if let Some(p) = cur_pipe.as_mut() => {
|
||||
p.to = text;
|
||||
}
|
||||
"CircPipe" if let Some(p) = cur_pipe.as_mut() => {
|
||||
if let Ok(d) = text.parse::<f64>() {
|
||||
p.diameter_ft = to_diameter_ft(d, doc.diameter_unit);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match name.as_str() {
|
||||
"Imperial" => in_imperial = false,
|
||||
"Metric" => in_metric = false,
|
||||
"Struct" => {
|
||||
if let Some(s) = cur_struct.take() {
|
||||
if let Some(net) = cur_network.as_mut() {
|
||||
if s.rim <= s.invert {
|
||||
let mut s = s;
|
||||
s.rim = s.invert + 5.0;
|
||||
net.structures.push(s);
|
||||
} else {
|
||||
net.structures.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"Pipe" => {
|
||||
if let Some(p) = cur_pipe.take() {
|
||||
if !p.from.is_empty() && !p.to.is_empty() {
|
||||
if let Some(net) = cur_network.as_mut() {
|
||||
net.pipes.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"Structs" => in_structs = false,
|
||||
"Pipes" => in_pipes = false,
|
||||
"PipeNetwork" => {
|
||||
if let Some(net) = cur_network.take() {
|
||||
if !net.structures.is_empty() {
|
||||
doc.networks.push(net);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
text_buf.clear();
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(e) => return Err(format!("LandXML parse error at {}: {e}", reader.error_position())),
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
|
||||
if doc.networks.is_empty() {
|
||||
return Err("LandXML: no pipe network with structures found".into());
|
||||
}
|
||||
|
||||
// Fill missing inverts from rim when needed.
|
||||
for net in &mut doc.networks {
|
||||
for s in &mut net.structures {
|
||||
if s.invert == 0.0 && s.rim != 0.0 {
|
||||
s.invert = s.rim - 5.0;
|
||||
}
|
||||
if s.rim == 0.0 && s.invert != 0.0 {
|
||||
s.rim = s.invert + 5.0;
|
||||
}
|
||||
}
|
||||
drop_dangling_pipes(net);
|
||||
}
|
||||
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
fn drop_dangling_pipes(net: &mut LandXmlNetwork) {
|
||||
let names: std::collections::HashSet<_> = net.structures.iter().map(|s| s.name.as_str()).collect();
|
||||
net.pipes.retain(|p| names.contains(p.from.as_str()) && names.contains(p.to.as_str()));
|
||||
}
|
||||
|
||||
fn local_name(tag: &str) -> String {
|
||||
tag.rsplit(':').next().unwrap_or(tag).to_string()
|
||||
}
|
||||
|
||||
fn attr_value(e: &quick_xml::events::BytesStart<'_>, key: &str) -> Option<String> {
|
||||
e.attributes()
|
||||
.filter_map(|a| a.ok())
|
||||
.find(|a| a.key.as_ref() == key.as_bytes())
|
||||
.and_then(|a| String::from_utf8(a.value.into_owned()).ok())
|
||||
}
|
||||
|
||||
fn parse_coords(text: &str) -> Option<(f64, f64, f64)> {
|
||||
let nums: Vec<f64> = text.split_whitespace().filter_map(|s| s.parse().ok()).collect();
|
||||
match nums.len() {
|
||||
0 => None,
|
||||
1 => Some((nums[0], 0.0, 0.0)),
|
||||
2 => Some((nums[0], nums[1], 0.0)),
|
||||
_ => Some((nums[0], nums[1], nums[2])),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_linear_unit(s: &str) -> LinearUnit {
|
||||
let l = s.to_ascii_lowercase();
|
||||
if l.contains("meter") || l == "m" {
|
||||
LinearUnit::Meter
|
||||
} else {
|
||||
LinearUnit::Foot
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_diameter_unit(s: &str) -> DiameterUnit {
|
||||
let l = s.to_ascii_lowercase();
|
||||
if l.contains("milli") {
|
||||
DiameterUnit::Millimeter
|
||||
} else if l.contains("meter") || l == "m" {
|
||||
DiameterUnit::Meter
|
||||
} else if l.contains("foot") || l == "ft" {
|
||||
DiameterUnit::Foot
|
||||
} else {
|
||||
DiameterUnit::Inch
|
||||
}
|
||||
}
|
||||
|
||||
fn to_linear_ft(v: f64, unit: LinearUnit) -> f64 {
|
||||
match unit {
|
||||
LinearUnit::Foot => v,
|
||||
LinearUnit::Meter => v * 3.280_839_895,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_diameter_ft(v: f64, unit: DiameterUnit) -> f64 {
|
||||
match unit {
|
||||
DiameterUnit::Inch => v / 12.0,
|
||||
DiameterUnit::Foot => v,
|
||||
DiameterUnit::Millimeter => v / 304.8,
|
||||
DiameterUnit::Meter => v * 3.280_839_895,
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_kind(name: &str, role: &str) -> NodeKind {
|
||||
let n = name.to_ascii_lowercase();
|
||||
let r = role.to_ascii_lowercase();
|
||||
if r.contains("outfall") || n.contains("outfall") || n.starts_with("of") {
|
||||
NodeKind::Outfall
|
||||
} else if r.contains("inlet") || n.contains("inlet") || n.starts_with("in") {
|
||||
NodeKind::Inlet
|
||||
} else if r.contains("junction") || n.contains("mh") || n.contains("manhole") || n.contains("cb") {
|
||||
NodeKind::Junction
|
||||
} else {
|
||||
NodeKind::Junction
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a parsed LandXML network into a stormsewer [`Network`](crate::network::Network).
|
||||
pub fn network_from_landxml(net: &LandXmlNetwork) -> Result<crate::network::Network, String> {
|
||||
use crate::network::{Network, Node, Pipe};
|
||||
use std::collections::HashMap;
|
||||
|
||||
if net.structures.is_empty() {
|
||||
return Err("LandXML network has no structures".into());
|
||||
}
|
||||
|
||||
let mut id_of: HashMap<String, String> = HashMap::new();
|
||||
let mut nodes = Vec::with_capacity(net.structures.len());
|
||||
for (i, s) in net.structures.iter().enumerate() {
|
||||
let id = format!("N{}", i + 1);
|
||||
id_of.insert(s.name.clone(), id.clone());
|
||||
let node = match s.kind {
|
||||
NodeKind::Inlet => Node::inlet(&id, s.invert, s.rim, s.area_ac, s.c),
|
||||
NodeKind::Junction => Node::junction(&id, s.invert, s.rim, s.area_ac, s.c),
|
||||
NodeKind::Outfall => Node::outfall(&id, s.invert, s.rim),
|
||||
}
|
||||
.at(s.x, s.y);
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
let coord: HashMap<_, _> = net.structures.iter().map(|s| (s.name.as_str(), (s.x, s.y))).collect();
|
||||
let mut pipes = Vec::new();
|
||||
for (k, p) in net.pipes.iter().enumerate() {
|
||||
let Some(from_id) = id_of.get(&p.from) else { continue };
|
||||
let Some(to_id) = id_of.get(&p.to) else { continue };
|
||||
let length = match (coord.get(p.from.as_str()), coord.get(p.to.as_str())) {
|
||||
(Some((x0, y0)), Some((x1, y1))) => ((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt(),
|
||||
_ => 100.0,
|
||||
};
|
||||
pipes.push(Pipe::new(
|
||||
&format!("P{}", k + 1),
|
||||
from_id,
|
||||
to_id,
|
||||
length,
|
||||
p.diameter_ft,
|
||||
p.n,
|
||||
));
|
||||
}
|
||||
|
||||
if pipes.is_empty() {
|
||||
return Err("LandXML network has no connected pipes".into());
|
||||
}
|
||||
|
||||
Ok(Network { nodes, pipes })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LandXML xmlns="http://www.landxml.org/schema/LandXML-1.2" version="1.2" date="2026-06-09">
|
||||
<Units>
|
||||
<Imperial areaUnit="squareFoot" linearUnit="foot" diameterUnit="inch"/>
|
||||
</Units>
|
||||
<PipeNetworks>
|
||||
<PipeNetwork name="Main">
|
||||
<Structs>
|
||||
<Struct name="IN1" role="inlet">
|
||||
<Center>0.0 0.0 110.0</Center>
|
||||
<Invert>104.0</Invert>
|
||||
<ElevRim>110.0</ElevRim>
|
||||
</Struct>
|
||||
<Struct name="OUT1" role="outfall">
|
||||
<Center>300.0 0.0 106.0</Center>
|
||||
<Invert>100.0</Invert>
|
||||
<ElevRim>106.0</ElevRim>
|
||||
</Struct>
|
||||
</Structs>
|
||||
<Pipes>
|
||||
<Pipe name="P1">
|
||||
<CircPipe diameter="18"/>
|
||||
<StartStruct>IN1</StartStruct>
|
||||
<EndStruct>OUT1</EndStruct>
|
||||
</Pipe>
|
||||
</Pipes>
|
||||
</PipeNetwork>
|
||||
</PipeNetworks>
|
||||
</LandXML>"#;
|
||||
|
||||
#[test]
|
||||
fn parses_sample_network() {
|
||||
let doc = parse_landxml(SAMPLE).expect("parse");
|
||||
let net = doc.primary_network().unwrap();
|
||||
assert_eq!(net.structures.len(), 2);
|
||||
assert_eq!(net.pipes.len(), 1);
|
||||
assert!((net.pipes[0].diameter_ft - 1.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_engine_network() {
|
||||
let doc = parse_landxml(SAMPLE).unwrap();
|
||||
let net = doc.primary_network().unwrap();
|
||||
let engine = network_from_landxml(net).unwrap();
|
||||
assert_eq!(engine.nodes.len(), 2);
|
||||
assert_eq!(engine.pipes.len(), 1);
|
||||
assert!((engine.pipes[0].length - 300.0).abs() < 1e-3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
pub mod landxml;
|
||||
|
||||
pub use landxml::*;
|
||||
@@ -10,8 +10,9 @@
|
||||
//! * **Rational method** peak-flow accumulation down a pipe network,
|
||||
//! * **Manning** open-channel / partial-flow hydraulics for circular conduits,
|
||||
//! * normal-depth, critical-depth and full-flow capacity,
|
||||
//! * *(forthcoming)* **HEC-22** hydraulic-grade-line backwater with junction
|
||||
//! and structure losses.
|
||||
//! * **HGL backwater** with junction losses and **standard-pipe sizing**
|
||||
//! against velocity / capacity criteria (Hydraflow-style design checks).
|
||||
//! * *(forthcoming)* full **HEC-22** inlet capacity and multi-return-period IDF sets.
|
||||
//!
|
||||
//! This is an **engine only**: no GUI and no CAD dependencies, so it compiles
|
||||
//! to a native library, to WASM (for hydrocomplete.com), and is consumable as
|
||||
@@ -31,15 +32,25 @@
|
||||
//! assert!((results[0].design_q - 5.6).abs() < 1e-6); // 4 * (0.7*2.0)
|
||||
//! ```
|
||||
|
||||
pub mod catchment;
|
||||
pub mod design;
|
||||
pub mod drawing;
|
||||
pub mod hydraulics;
|
||||
pub mod hydrology;
|
||||
pub mod idf;
|
||||
pub mod io;
|
||||
pub mod network;
|
||||
pub mod params;
|
||||
pub mod parse;
|
||||
pub mod report;
|
||||
|
||||
pub use catchment::*;
|
||||
pub use design::*;
|
||||
pub use drawing::*;
|
||||
pub use hydraulics::*;
|
||||
pub use hydrology::*;
|
||||
pub use idf::*;
|
||||
pub use io::*;
|
||||
pub use network::*;
|
||||
pub use params::*;
|
||||
pub use parse::*;
|
||||
@@ -8,8 +8,11 @@
|
||||
//! standard for gravity storm sewers. Looped networks are rejected by the
|
||||
//! topological sort.
|
||||
|
||||
use crate::design::{size_network, DesignCriteria, PipeSizeRecommendation};
|
||||
use crate::hydraulics::*;
|
||||
use crate::hydrology::IdfSet;
|
||||
use crate::idf::IdfCurve;
|
||||
use crate::params::StormAnalysisParams;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Kind of network node.
|
||||
@@ -151,7 +154,7 @@ pub struct Analysis {
|
||||
}
|
||||
|
||||
/// Options for [`Network::analyze`].
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AnalysisOptions {
|
||||
/// Minimum time of concentration (minutes) — floors the IDF duration.
|
||||
pub min_tc: f64,
|
||||
@@ -254,6 +257,40 @@ impl Network {
|
||||
Ok(self.analyze(&IdfCurve::new(0.0, 1.0, 1.0), &opts)?.pipes)
|
||||
}
|
||||
|
||||
/// Analyze then recommend standard pipe diameters for each link.
|
||||
pub fn analyze_and_size(
|
||||
&self,
|
||||
idf: &IdfCurve,
|
||||
opts: &AnalysisOptions,
|
||||
criteria: &DesignCriteria,
|
||||
) -> Result<(Analysis, Vec<PipeSizeRecommendation>), NetworkError> {
|
||||
let a = self.analyze(idf, opts)?;
|
||||
let recs = size_network(self, &a, criteria);
|
||||
Ok((a, recs))
|
||||
}
|
||||
|
||||
/// Full analyze + size using [`StormAnalysisParams`].
|
||||
pub fn analyze_and_size_params(
|
||||
&self,
|
||||
params: &StormAnalysisParams,
|
||||
) -> Result<(Analysis, Vec<PipeSizeRecommendation>), NetworkError> {
|
||||
self.analyze_and_size(params.idf.design_curve(), ¶ms.hydraulics, ¶ms.sizing)
|
||||
}
|
||||
|
||||
/// Run analysis at every configured return period.
|
||||
pub fn analyze_all_rps(
|
||||
&self,
|
||||
idf_set: &IdfSet,
|
||||
opts: &AnalysisOptions,
|
||||
) -> Result<Vec<(u32, Analysis)>, NetworkError> {
|
||||
let mut out = Vec::new();
|
||||
for rp in idf_set.return_periods() {
|
||||
let curve = idf_set.curve(rp).expect("return_periods keys exist");
|
||||
out.push((rp, self.analyze(curve, opts)?));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Full analysis: Tc accumulation, per-pipe IDF intensity, Rational design
|
||||
/// flows, pipe hydraulics, and an HGL backwater pass with junction losses.
|
||||
pub fn analyze(&self, idf: &IdfCurve, opts: &AnalysisOptions) -> Result<Analysis, NetworkError> {
|
||||
@@ -513,6 +550,18 @@ mod tests {
|
||||
assert!(p2.intensity <= p1.intensity, "i2 {} i1 {}", p2.intensity, p1.intensity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_all_return_periods() {
|
||||
use crate::hydrology::IdfSet;
|
||||
let mut idf_set = IdfSet::default();
|
||||
idf_set.set_curve(25, IdfCurve::new(90.0, 12.0, 0.8));
|
||||
let results = sample().analyze_all_rps(&idf_set, &AnalysisOptions::default()).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
let q10 = results.iter().find(|(rp, _)| *rp == 10).unwrap().1.pipes[1].design_q;
|
||||
let q25 = results.iter().find(|(rp, _)| *rp == 25).unwrap().1.pipes[1].design_q;
|
||||
assert!(q25 > q10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hgl_rises_upstream() {
|
||||
// With a tailwater, HGL must be monotonically higher going upstream.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Global storm-sewer analysis parameters (hydrology, hydraulics, sizing).
|
||||
|
||||
use crate::design::DesignCriteria;
|
||||
use crate::hydrology::IdfSet;
|
||||
use crate::network::AnalysisOptions;
|
||||
|
||||
/// Network-level parameters for analyze / size / report passes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StormAnalysisParams {
|
||||
pub idf: IdfSet,
|
||||
pub hydraulics: AnalysisOptions,
|
||||
pub sizing: DesignCriteria,
|
||||
/// Default grate length (ft) for HEC-22 inlet capacity checks at inlets.
|
||||
pub inlet_grate_length_ft: f64,
|
||||
/// Assumed gutter flow depth (ft) at the curb for inlet checks.
|
||||
pub inlet_flow_depth_ft: f64,
|
||||
/// Assumed gutter longitudinal slope (ft/ft) for inlet checks.
|
||||
pub inlet_gutter_slope: f64,
|
||||
}
|
||||
|
||||
impl Default for StormAnalysisParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
idf: IdfSet::municipal_default(),
|
||||
hydraulics: AnalysisOptions::default(),
|
||||
sizing: DesignCriteria::municipal(),
|
||||
inlet_grate_length_ft: 2.0,
|
||||
inlet_flow_depth_ft: 0.15,
|
||||
inlet_gutter_slope: 0.005,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StormAnalysisParams {
|
||||
pub fn municipal() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Summary for command-line / dialog display.
|
||||
pub fn summary(&self) -> String {
|
||||
let c = self.idf.design_curve();
|
||||
let tw = self
|
||||
.hydraulics
|
||||
.tailwater
|
||||
.map(|t| format!("{t:.2} ft"))
|
||||
.unwrap_or_else(|| "free".into());
|
||||
format!(
|
||||
"RP {}yr IDF i=a/(t+b)^c a={:.1} b={:.1} c={:.2} tailwater={tw} minTc={:.0}min junctionK={:.2} V={:.1}-{:.1} ft/s maxFull={:.0}%",
|
||||
self.idf.design_rp,
|
||||
c.a,
|
||||
c.b,
|
||||
c.c,
|
||||
self.hydraulics.min_tc,
|
||||
self.hydraulics.junction_k,
|
||||
self.sizing.min_velocity,
|
||||
self.sizing.max_velocity,
|
||||
self.sizing.max_pct_full * 100.0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn summary_includes_design_rp() {
|
||||
let p = StormAnalysisParams::default();
|
||||
assert!(p.summary().contains("RP 10yr"));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Plain-text report tables for an [`Analysis`], in the spirit of Hydraflow
|
||||
//! Storm Sewers' pipe and HGL summaries.
|
||||
//! Plain-text report tables for an [`Analysis`] and pipe-sizing output, in the
|
||||
//! spirit of Hydraflow Storm Sewers' pipe and HGL summaries.
|
||||
|
||||
use crate::network::Analysis;
|
||||
use crate::design::PipeSizeRecommendation;
|
||||
use crate::hydrology::IdfSet;
|
||||
use crate::network::{Analysis, AnalysisOptions, Network};
|
||||
|
||||
fn f(x: f64, w: usize, p: usize) -> String {
|
||||
format!("{:>w$.p$}", x, w = w, p = p)
|
||||
@@ -68,6 +70,92 @@ pub fn node_table(a: &Analysis) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
fn dia_in(d_ft: f64) -> String {
|
||||
format!("{}\"", (d_ft * 12.0).round() as i32)
|
||||
}
|
||||
|
||||
/// Pipe-sizing recommendations table.
|
||||
pub fn sizing_table(recs: &[PipeSizeRecommendation]) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str("Pipe Q(cfs) Slope Current Rec'd %Full V(ft/s) Status\n");
|
||||
s.push_str(&"-".repeat(72));
|
||||
s.push('\n');
|
||||
for r in recs {
|
||||
let status = match r.outcome {
|
||||
crate::design::SizeOutcome::Adequate => "ok",
|
||||
crate::design::SizeOutcome::Sized => "UPSIZE",
|
||||
crate::design::SizeOutcome::NoSolution => "NO SIZE",
|
||||
};
|
||||
s.push_str(&format!(
|
||||
"{:<6} {} {} {} {} {} {} {}\n",
|
||||
r.pipe_id,
|
||||
f(r.design_q, 6, 2),
|
||||
f(r.slope, 7, 4),
|
||||
format!("{:>6}", dia_in(r.current_diameter_ft)),
|
||||
format!("{:>6}", dia_in(r.recommended_diameter_ft)),
|
||||
f(r.pct_full * 100.0, 6, 1),
|
||||
f(r.velocity, 6, 2),
|
||||
status,
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Full sizing report with per-pipe notes.
|
||||
pub fn format_sizing(recs: &[PipeSizeRecommendation]) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str("=== STORM SEWER PIPE SIZING ===\n\n");
|
||||
s.push_str(&sizing_table(recs));
|
||||
s.push('\n');
|
||||
for r in recs {
|
||||
s.push_str(&r.note);
|
||||
s.push('\n');
|
||||
}
|
||||
let upsized: Vec<&str> = recs
|
||||
.iter()
|
||||
.filter(|r| r.outcome == crate::design::SizeOutcome::Sized)
|
||||
.map(|r| r.pipe_id.as_str())
|
||||
.collect();
|
||||
let failed: Vec<&str> = recs
|
||||
.iter()
|
||||
.filter(|r| r.outcome == crate::design::SizeOutcome::NoSolution)
|
||||
.map(|r| r.pipe_id.as_str())
|
||||
.collect();
|
||||
if upsized.is_empty() && failed.is_empty() {
|
||||
s.push_str("\nAll pipes meet design criteria.\n");
|
||||
} else {
|
||||
if !upsized.is_empty() {
|
||||
s.push_str(&format!("\nPipes to upsize: {}\n", upsized.join(", ")));
|
||||
}
|
||||
if !failed.is_empty() {
|
||||
s.push_str(&format!("Pipes with no catalog solution: {}\n", failed.join(", ")));
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Summary of peak design flows at each configured return period.
|
||||
pub fn format_multi_rp(net: &Network, idf_set: &IdfSet, opts: &AnalysisOptions) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str("=== MULTI RETURN-PERIOD PEAK FLOWS ===\n\n");
|
||||
s.push_str("RP(yr) Pipe Q(cfs) Surcharged\n");
|
||||
s.push_str(&"-".repeat(40));
|
||||
s.push('\n');
|
||||
match net.analyze_all_rps(idf_set, opts) {
|
||||
Ok(runs) => {
|
||||
for (rp, a) in runs {
|
||||
for p in &a.pipes {
|
||||
let flag = if p.surcharged { "yes" } else { "no" };
|
||||
s.push_str(&format!("{rp:<7} {:<6} {} {flag}\n", p.id, f(p.design_q, 6, 2)));
|
||||
}
|
||||
s.push('\n');
|
||||
}
|
||||
}
|
||||
Err(e) => s.push_str(&format!("error: {e}\n")),
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Full report: pipe table followed by node/HGL table.
|
||||
pub fn format_analysis(a: &Analysis) -> String {
|
||||
let mut s = String::new();
|
||||
|
||||
Reference in new issue
Block a user