feat: Adds hydronic system pressure calculator.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,40 @@
|
||||
import Dependencies
|
||||
import Logging
|
||||
import PsychrometricClient
|
||||
import Routes
|
||||
|
||||
public extension HydronicSystemPressure.Request {
|
||||
|
||||
func respond(logger: Logger) async throws -> HydronicSystemPressure.Response {
|
||||
@Dependency(\.psychrometricClient) var psychrometricClient
|
||||
|
||||
try validate()
|
||||
|
||||
let waterTemperature = DryBulb.fahrenheit(self.waterTemperature ?? 60)
|
||||
|
||||
let density = try await psychrometricClient.density.water(waterTemperature)
|
||||
let pressure = height * (density.value / 144) + 5
|
||||
var warnings = [String]()
|
||||
|
||||
if self.waterTemperature == nil {
|
||||
warnings.append(
|
||||
"""
|
||||
Calculations based on default water temperature of 60°F.
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
return .init(pressure: pressure, waterDensity: density, warnings: warnings)
|
||||
}
|
||||
|
||||
private func validate() throws {
|
||||
guard height > 0 else {
|
||||
throw ValidationError(message: "Height should be greater than 0.")
|
||||
}
|
||||
if let waterTemperature {
|
||||
guard waterTemperature > 32 else {
|
||||
throw ValidationError(message: "Water temperature should be above freezing.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
84
Sources/Routes/Models/HydronicSystemPressure.swift
Normal file
84
Sources/Routes/Models/HydronicSystemPressure.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import CasePaths
|
||||
import PsychrometricClient
|
||||
@preconcurrency import URLRouting
|
||||
|
||||
//
|
||||
public enum HydronicSystemPressure {
|
||||
|
||||
public static let description = """
|
||||
Calculate the required hydronic system pressure based on height of a building.
|
||||
"""
|
||||
|
||||
public struct Request: Codable, Equatable, Hashable, Sendable {
|
||||
public let height: Double
|
||||
public let waterTemperature: Double?
|
||||
|
||||
public init(height: Double, waterTemperature: Double? = nil) {
|
||||
self.height = height
|
||||
self.waterTemperature = waterTemperature
|
||||
}
|
||||
|
||||
public enum FieldKey: String, CaseIterable {
|
||||
case height
|
||||
case waterTemperature
|
||||
}
|
||||
}
|
||||
|
||||
public struct Response: Codable, Equatable, Sendable {
|
||||
|
||||
public let pressure: Double
|
||||
public let waterDensity: DensityOf<Water>
|
||||
public let warnings: [String]
|
||||
|
||||
public init(pressure: Double, waterDensity: DensityOf<Water>, warnings: [String]) {
|
||||
self.pressure = pressure
|
||||
self.waterDensity = waterDensity
|
||||
self.warnings = warnings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Router
|
||||
|
||||
public extension SiteRoute.View {
|
||||
|
||||
enum HydronicSystemPressure: Equatable, Hashable, Sendable {
|
||||
case index
|
||||
case submit(Routes.HydronicSystemPressure.Request)
|
||||
|
||||
typealias Key = Routes.HydronicSystemPressure.Request.FieldKey
|
||||
static let rootPath = "hydronic-system-pressure"
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.index)) {
|
||||
Path { rootPath }
|
||||
Method.get
|
||||
}
|
||||
Route(.case(Self.submit)) {
|
||||
Path { rootPath }
|
||||
Method.post
|
||||
Body {
|
||||
FormData {
|
||||
Field(Key.height) { Double.parser() }
|
||||
Optionally { Field(Key.waterTemperature) { Double.parser() } }
|
||||
}
|
||||
.map(.memberwise(Routes.HydronicSystemPressure.Request.init))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
public extension HydronicSystemPressure.Response {
|
||||
static let mock = Self(
|
||||
pressure: 15,
|
||||
waterDensity: 62.37,
|
||||
warnings: [
|
||||
"Water density based on 60 water - include water temperature for more accurate result."
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -107,6 +107,7 @@ public extension SiteRoute {
|
||||
case filterPressureDrop(FilterPressureDrop)
|
||||
case heatingBalancePoint(HeatingBalancePoint)
|
||||
case hvacSystemPerformance(HVACSystemPerformance)
|
||||
case hydronicSystemPressure(Self.HydronicSystemPressure)
|
||||
case moldRisk(MoldRisk)
|
||||
case psychrometrics(Self.Psychrometrics)
|
||||
case roomPressure(RoomPressure)
|
||||
@@ -133,6 +134,9 @@ public extension SiteRoute {
|
||||
Route(.case(Self.hvacSystemPerformance)) {
|
||||
HVACSystemPerformance.router
|
||||
}
|
||||
Route(.case(Self.hydronicSystemPressure)) {
|
||||
HydronicSystemPressure.router
|
||||
}
|
||||
Route(.case(Self.moldRisk)) {
|
||||
MoldRisk.router
|
||||
}
|
||||
|
||||
@@ -59,6 +59,17 @@ public struct Input: HTML, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public extension Input {
|
||||
|
||||
init<Key>(
|
||||
id: Key,
|
||||
name: String? = nil,
|
||||
placeholder: String
|
||||
) where Key: RawRepresentable, Key.RawValue == String {
|
||||
self.init(id: id.rawValue, name: name, placeholder: placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
/// A style form input label.
|
||||
public struct InputLabel<InputLabel: HTML>: HTML {
|
||||
|
||||
|
||||
17
Sources/Styleguide/ResultBox.swift
Normal file
17
Sources/Styleguide/ResultBox.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import Elementary
|
||||
|
||||
public struct ResultBox<Body: HTML>: HTML {
|
||||
let body: Body
|
||||
|
||||
public init(@HTMLBuilder body: () -> Body) {
|
||||
self.body = body()
|
||||
}
|
||||
|
||||
public var content: some HTML<HTMLTag.div> {
|
||||
div(.class("w-full rounded-lg shadow-lg bg-blue-100 border border-blue-600 text-blue-600 p-6")) {
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ResultBox: Sendable where Body: Sendable {}
|
||||
18
Sources/Styleguide/RoundedBox.swift
Normal file
18
Sources/Styleguide/RoundedBox.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Elementary
|
||||
|
||||
// A rounded box, with no color styles. Colors should be added at call site.
|
||||
public struct Box<Body: HTML>: HTML {
|
||||
let body: Body
|
||||
|
||||
public init(@HTMLBuilder body: () -> Body) {
|
||||
self.body = body()
|
||||
}
|
||||
|
||||
public var content: some HTML<HTMLTag.div> {
|
||||
div(.class("w-full rounded-lg shadow-lgp-6")) {
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Box: Sendable where Body: Sendable {}
|
||||
@@ -45,6 +45,7 @@ public struct SVGSize: Sendable {
|
||||
public enum SVGType: Sendable, CaseIterable {
|
||||
case calculator
|
||||
case checkCircle
|
||||
case circleGauge
|
||||
case droplets
|
||||
case exclamation
|
||||
case funnel
|
||||
@@ -63,6 +64,7 @@ public enum SVGType: Sendable, CaseIterable {
|
||||
switch self {
|
||||
case .calculator: return calculatorSvg(size: size)
|
||||
case .checkCircle: return checkCircleSvg(size: size)
|
||||
case .circleGauge: return circleGaugeSvg(size: size)
|
||||
case .droplets: return dropletsSvg(size: size)
|
||||
case .exclamation: return exclamationSvg(size: size)
|
||||
case .funnel: return funnelSvg(size: size)
|
||||
@@ -84,6 +86,16 @@ public enum SVGType: Sendable, CaseIterable {
|
||||
|
||||
// swiftlint:disable line_length
|
||||
|
||||
private func circleGaugeSvg(size: SVGSize) -> HTMLRaw {
|
||||
HTMLRaw("""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="\(size.width)" height="\(size.height)" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-gauge">
|
||||
<path d="M15.6 2.7a10 10 0 1 0 5.7 5.7"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<path d="M13.4 10.6 19 5"/>
|
||||
</svg>
|
||||
""")
|
||||
}
|
||||
|
||||
private func scaleSvg(size: SVGSize) -> HTMLRaw {
|
||||
HTMLRaw("""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="\(size.width)" height="\(size.height)" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-scale">
|
||||
|
||||
3
Sources/ViewController/Extensions/String+units.swift
Normal file
3
Sources/ViewController/Extensions/String+units.swift
Normal file
@@ -0,0 +1,3 @@
|
||||
public extension String {
|
||||
static let fahrenheit = "°F"
|
||||
}
|
||||
@@ -61,6 +61,12 @@ struct HomePage: HTML, Sendable {
|
||||
svg: .droplets,
|
||||
route: .psychrometrics(.index)
|
||||
)
|
||||
group(
|
||||
label: "Hydronic System Pressure",
|
||||
description: HydronicSystemPressure.description,
|
||||
svg: .circleGauge,
|
||||
route: .hydronicSystemPressure(.index)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,15 @@ extension ViewController: DependencyKey {
|
||||
return HVACSystemPerformanceResult(response: response)
|
||||
}
|
||||
|
||||
case let .hydronicSystemPressure(route):
|
||||
switch route {
|
||||
case .index:
|
||||
return request.respond(HydronicSystemPressureForm(response: nil))
|
||||
case let .submit(request):
|
||||
let response = try await request.respond(logger: logger)
|
||||
return HydronicSystemPressureResponse(response: response)
|
||||
}
|
||||
|
||||
case let .moldRisk(route):
|
||||
switch route {
|
||||
case .index:
|
||||
|
||||
74
Sources/ViewController/Views/HydronicSystemPressure.swift
Normal file
74
Sources/ViewController/Views/HydronicSystemPressure.swift
Normal file
@@ -0,0 +1,74 @@
|
||||
import Elementary
|
||||
import ElementaryHTMX
|
||||
import PsychrometricClient
|
||||
import Routes
|
||||
import Styleguide
|
||||
|
||||
struct HydronicSystemPressureForm: HTML, Sendable {
|
||||
let response: HydronicSystemPressure.Response?
|
||||
|
||||
typealias Key = HydronicSystemPressure.Request.FieldKey
|
||||
|
||||
var content: some HTML {
|
||||
FormHeader(label: "Hydronic System Pressure", svg: .circleGauge)
|
||||
|
||||
form(
|
||||
.hx.post(route: .hydronicSystemPressure(.index)),
|
||||
.hx.target("#result")
|
||||
) {
|
||||
div(.class("space-y-6")) {
|
||||
div(.class("grid grid-cols-2 gap-4")) {
|
||||
LabeledContent(label: "Height (ft.)") {
|
||||
Input(id: Key.height, placeholder: "Building height")
|
||||
.attributes(.type(.number), .min("0.1"), .step("0.1"), .autofocus, .required)
|
||||
}
|
||||
|
||||
LabeledContent(label: "Water Temperature (\(String.fahrenheit))") {
|
||||
Input(id: Key.waterTemperature, placeholder: "Temperature (optional)")
|
||||
.attributes(.type(.number), .min("32.0"), .step("0.1"))
|
||||
}
|
||||
}
|
||||
|
||||
Note {
|
||||
"""
|
||||
Water temperature should be the coldest water temperature the system sees, which for boilers will be
|
||||
when the system is filled with water.
|
||||
"""
|
||||
}
|
||||
|
||||
div {
|
||||
SubmitButton(label: "Calculate System Pressure")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div(.id("result")) {
|
||||
if let response {
|
||||
HydronicSystemPressureResponse(response: response)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HydronicSystemPressureResponse: HTML, Sendable {
|
||||
let response: HydronicSystemPressure.Response
|
||||
|
||||
var content: some HTML {
|
||||
ResultContainer(reset: .hydronicSystemPressure(.index)) {
|
||||
ResultBox {
|
||||
div(.class("grid grid-cols-2")) {
|
||||
VerticalGroup(label: "Pressure", value: "\(double: response.pressure)", valueLabel: "psi")
|
||||
VerticalGroup(
|
||||
label: "Water Density",
|
||||
value: "\(double: response.waterDensity.value)",
|
||||
valueLabel: response.waterDensity.units.rawValue
|
||||
)
|
||||
}
|
||||
}
|
||||
WarningBox(warnings: response.warnings)
|
||||
Note {
|
||||
"Expansion tank pressure should match system fill pressure."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,11 @@ struct ViewControllerTests {
|
||||
doorHeight: 86,
|
||||
doorUndercut: 1,
|
||||
preferredGrilleHeight: .fourteen
|
||||
))))
|
||||
)))),
|
||||
|
||||
// Hydronic system pressure
|
||||
.hydronicSystemPressure(.index),
|
||||
.hydronicSystemPressure(.submit(.init(height: 12, waterTemperature: 60)))
|
||||
|
||||
])
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="block text-blue-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-gauge">
|
||||
<path d="M15.6 2.7a10 10 0 1 0 5.7 5.7"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<path d="M13.4 10.6 19 5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-extrabold">Hydronic System Pressure</h2>
|
||||
</div>
|
||||
<form hx-post="/hydronic-system-pressure" hx-target="#result">
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="height" class="block text-sm font-medium mb-2">Height (ft.)</label>
|
||||
<input id="height" placeholder="Building height" name="height" class=" w-full px-4 py-2 border rounded-md min-h-11
|
||||
focus:ring-2 focus:ring-yellow-800 focus:border-yellow-800
|
||||
placeholder-shown:!border-gray-400
|
||||
invalid:border-red-500 out-of-range:border-red-500" type="number" min="0.1" step="0.1" autofocus required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="waterTemperature" class="block text-sm font-medium mb-2">Water Temperature (°F)</label>
|
||||
<input id="waterTemperature" placeholder="Temperature (optional)" name="waterTemperature" class=" w-full px-4 py-2 border rounded-md min-h-11
|
||||
focus:ring-2 focus:ring-yellow-800 focus:border-yellow-800
|
||||
placeholder-shown:!border-gray-400
|
||||
invalid:border-red-500 out-of-range:border-red-500" type="number" min="32.0" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 p-4 bg-gray-100 dark:bg-gray-700 rounded-md shadow-md
|
||||
border border-blue-500 text-blue-500 text-sm">
|
||||
<p class="font-extrabold mb-3">Note:</p>
|
||||
<p class="px-6">
|
||||
Water temperature should be the coldest water temperature the system sees, which for boilers will be
|
||||
when the system is filled with water.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class=" w-full font-bold py-3 rounded-md transition-colors
|
||||
bg-yellow-300 dark:bg-blue-500
|
||||
hover:bg-yellow-400 hover:dark:bg-blue-600
|
||||
text-blue-500 dark:text-yellow-300">Calculate System Pressure</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div id="result"></div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<div class=" mt-6 p-6 rounded-lg border border-blue-500
|
||||
bg-blue-50 dark:bg-slate-600
|
||||
text-blue-500 dark:text-slate-200">
|
||||
<div class="relative">
|
||||
<h3 class="text-xl font-semibold mb-4">Results</h3>
|
||||
<button class=" font-bold px-4 py-2 rounded-md transition-colors
|
||||
bg-blue-500 dark:bg-yellow-300
|
||||
hover:bg-blue-600 hover:dark:bg-yellow-400
|
||||
text-yellow-300 dark:text-blue-500 absolute bottom-0 right-0" hx-get="/hydronic-system-pressure" hx-target="#content">Reset</button>
|
||||
</div>
|
||||
<div class="w-full rounded-lg shadow-lg bg-blue-100 border border-blue-600 text-blue-600 p-6">
|
||||
<div class="grid grid-cols-2">
|
||||
<div class="grid grid-cols-1 justify-items-center">
|
||||
<p class="font-medium">Pressure</p>
|
||||
<h3 class="text-3xl font-extrabold">10.22<span class="text-lg ms-2">psi</span></h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 justify-items-center">
|
||||
<p class="font-medium">Water Density</p>
|
||||
<h3 class="text-3xl font-extrabold">62.58<span class="text-lg ms-2">lb/ft³</span></h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="warnings"></div>
|
||||
<div class="mt-8 p-4 bg-gray-100 dark:bg-gray-700 rounded-md shadow-md
|
||||
border border-blue-500 text-blue-500 text-sm">
|
||||
<p class="font-extrabold mb-3">Note:</p>
|
||||
<p class="px-6">Expansion tank pressure should match system fill pressure.</p>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user