feat: Adds psi to feet of head conversion.
This commit is contained in:
File diff suppressed because one or more lines are too long
38
Sources/ApiController/Extensions/FeetOfHead.swift
Normal file
38
Sources/ApiController/Extensions/FeetOfHead.swift
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Dependencies
|
||||||
|
import Logging
|
||||||
|
import PsychrometricClient
|
||||||
|
import Routes
|
||||||
|
|
||||||
|
public extension FeetOfHead.Request {
|
||||||
|
|
||||||
|
private static let specificGravity = 1.02
|
||||||
|
|
||||||
|
func respond(logger: Logger) async throws -> FeetOfHead.Response {
|
||||||
|
@Dependency(\.psychrometricClient) var psychrometricClient
|
||||||
|
|
||||||
|
try validate()
|
||||||
|
let waterTemperature = DryBulb.fahrenheit(self.waterTemperature ?? 60)
|
||||||
|
let density = try await psychrometricClient.density.water(waterTemperature)
|
||||||
|
let feetOfHead = pressure / ((density.value / 144) * Self.specificGravity)
|
||||||
|
var warnings = [String]()
|
||||||
|
|
||||||
|
if self.waterTemperature == nil {
|
||||||
|
warnings.append(
|
||||||
|
"Calculations are based on 60°F water temperature."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .init(feetOfHead: feetOfHead, density: density, warnings: warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validate() throws {
|
||||||
|
guard pressure > 0 else {
|
||||||
|
throw ValidationError(message: "Pressure should be greater than 0.")
|
||||||
|
}
|
||||||
|
if let waterTemperature {
|
||||||
|
guard waterTemperature > 32 else {
|
||||||
|
throw ValidationError(message: "Water temperature should be above freezing.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
Sources/Routes/Models/FeetOfHead.swift
Normal file
74
Sources/Routes/Models/FeetOfHead.swift
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import CasePaths
|
||||||
|
import PsychrometricClient
|
||||||
|
@preconcurrency import URLRouting
|
||||||
|
|
||||||
|
public enum FeetOfHead {
|
||||||
|
public static let description = """
|
||||||
|
Convert PSI to Feet of Head, to aid in pump flow calculations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
public struct Request: Codable, Equatable, Hashable, Sendable {
|
||||||
|
|
||||||
|
public let pressure: Double
|
||||||
|
public let waterTemperature: Double?
|
||||||
|
|
||||||
|
public init(pressure: Double, waterTemperature: Double? = nil) {
|
||||||
|
self.pressure = pressure
|
||||||
|
self.waterTemperature = waterTemperature
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FieldKey: String, CaseIterable {
|
||||||
|
case pressure
|
||||||
|
case waterTemperature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Response: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let feetOfHead: Double
|
||||||
|
public let density: DensityOf<Water>
|
||||||
|
public let warnings: [String]
|
||||||
|
|
||||||
|
public init(feetOfHead: Double, density: DensityOf<Water>, warnings: [String]) {
|
||||||
|
self.feetOfHead = feetOfHead
|
||||||
|
self.density = density
|
||||||
|
self.warnings = warnings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Router
|
||||||
|
|
||||||
|
public extension SiteRoute.View {
|
||||||
|
enum FeetOfHead: Equatable, Hashable, Sendable {
|
||||||
|
case index
|
||||||
|
case submit(Routes.FeetOfHead.Request)
|
||||||
|
|
||||||
|
static let rootPath = "feet-of-head"
|
||||||
|
typealias Key = Routes.FeetOfHead.Request.FieldKey
|
||||||
|
|
||||||
|
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.pressure) { Double.parser() }
|
||||||
|
Optionally { Field(Key.waterTemperature) { Double.parser() } }
|
||||||
|
}
|
||||||
|
.map(.memberwise(Routes.FeetOfHead.Request.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
public extension FeetOfHead.Response {
|
||||||
|
static let mock = Self(feetOfHead: 7.95, density: 62.37, warnings: [])
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -2,7 +2,6 @@ import CasePaths
|
|||||||
import PsychrometricClient
|
import PsychrometricClient
|
||||||
@preconcurrency import URLRouting
|
@preconcurrency import URLRouting
|
||||||
|
|
||||||
//
|
|
||||||
public enum HydronicSystemPressure {
|
public enum HydronicSystemPressure {
|
||||||
|
|
||||||
public static let description = """
|
public static let description = """
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ public extension SiteRoute {
|
|||||||
case atticVentilation(AtticVentilation)
|
case atticVentilation(AtticVentilation)
|
||||||
case capacitor(Capacitor)
|
case capacitor(Capacitor)
|
||||||
case dehumidifierSize(DehumidifierSize)
|
case dehumidifierSize(DehumidifierSize)
|
||||||
|
case feetOfHead(Self.FeetOfHead)
|
||||||
case filterPressureDrop(FilterPressureDrop)
|
case filterPressureDrop(FilterPressureDrop)
|
||||||
case heatingBalancePoint(HeatingBalancePoint)
|
case heatingBalancePoint(HeatingBalancePoint)
|
||||||
case hvacSystemPerformance(HVACSystemPerformance)
|
case hvacSystemPerformance(HVACSystemPerformance)
|
||||||
@@ -125,6 +126,9 @@ public extension SiteRoute {
|
|||||||
Route(.case(Self.dehumidifierSize)) {
|
Route(.case(Self.dehumidifierSize)) {
|
||||||
DehumidifierSize.router
|
DehumidifierSize.router
|
||||||
}
|
}
|
||||||
|
Route(.case(Self.feetOfHead)) {
|
||||||
|
FeetOfHead.router
|
||||||
|
}
|
||||||
Route(.case(Self.filterPressureDrop)) {
|
Route(.case(Self.filterPressureDrop)) {
|
||||||
FilterPressureDrop.router
|
FilterPressureDrop.router
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public enum SVGType: Sendable, CaseIterable {
|
|||||||
case circleGauge
|
case circleGauge
|
||||||
case droplets
|
case droplets
|
||||||
case exclamation
|
case exclamation
|
||||||
|
case footprints
|
||||||
case funnel
|
case funnel
|
||||||
case house
|
case house
|
||||||
case leftRightArrow
|
case leftRightArrow
|
||||||
@@ -67,6 +68,7 @@ public enum SVGType: Sendable, CaseIterable {
|
|||||||
case .circleGauge: return circleGaugeSvg(size: size)
|
case .circleGauge: return circleGaugeSvg(size: size)
|
||||||
case .droplets: return dropletsSvg(size: size)
|
case .droplets: return dropletsSvg(size: size)
|
||||||
case .exclamation: return exclamationSvg(size: size)
|
case .exclamation: return exclamationSvg(size: size)
|
||||||
|
case .footprints: return footprintsSvg(size: size)
|
||||||
case .funnel: return funnelSvg(size: size)
|
case .funnel: return funnelSvg(size: size)
|
||||||
case .house: return houseSvg(size: size)
|
case .house: return houseSvg(size: size)
|
||||||
case .leftRightArrow: return leftRightArrowSvg(size: size)
|
case .leftRightArrow: return leftRightArrowSvg(size: size)
|
||||||
@@ -86,6 +88,17 @@ public enum SVGType: Sendable, CaseIterable {
|
|||||||
|
|
||||||
// swiftlint:disable line_length
|
// swiftlint:disable line_length
|
||||||
|
|
||||||
|
private func footprintsSvg(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-footprints">
|
||||||
|
<path d="M4 16v-2.38C4 11.5 2.97 10.5 3 8c.03-2.72 1.49-6 4.5-6C9.37 2 10 3.8 10 5.5c0 3.11-2 5.66-2 8.68V16a2 2 0 1 1-4 0Z"/>
|
||||||
|
<path d="M20 20v-2.38c0-2.12 1.03-3.12 1-5.62-.03-2.72-1.49-6-4.5-6C14.63 6 14 7.8 14 9.5c0 3.11 2 5.66 2 8.68V20a2 2 0 1 0 4 0Z"/>
|
||||||
|
<path d="M16 17h4"/>
|
||||||
|
<path d="M4 13h4"/>
|
||||||
|
</svg>
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
private func circleGaugeSvg(size: SVGSize) -> HTMLRaw {
|
private func circleGaugeSvg(size: SVGSize) -> HTMLRaw {
|
||||||
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">
|
<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">
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ struct HomePage: HTML, Sendable {
|
|||||||
svg: .circleGauge,
|
svg: .circleGauge,
|
||||||
route: .hydronicSystemPressure(.index)
|
route: .hydronicSystemPressure(.index)
|
||||||
)
|
)
|
||||||
|
group(
|
||||||
|
label: "Feet of Head",
|
||||||
|
description: FeetOfHead.description,
|
||||||
|
svg: .footprints,
|
||||||
|
route: .feetOfHead(.index)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ extension ViewController: DependencyKey {
|
|||||||
return DehumidifierSizeResult(response: response)
|
return DehumidifierSizeResult(response: response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case let .feetOfHead(route):
|
||||||
|
switch route {
|
||||||
|
case .index:
|
||||||
|
return request.respond(FeetOfHeadForm(response: nil))
|
||||||
|
case let .submit(request):
|
||||||
|
let response = try await request.respond(logger: logger)
|
||||||
|
return FeetOfHeadResponse(response: response)
|
||||||
|
}
|
||||||
|
|
||||||
case let .filterPressureDrop(route):
|
case let .filterPressureDrop(route):
|
||||||
switch route {
|
switch route {
|
||||||
case let .index(mode: mode):
|
case let .index(mode: mode):
|
||||||
|
|||||||
70
Sources/ViewController/Views/FeetOfHead.swift
Normal file
70
Sources/ViewController/Views/FeetOfHead.swift
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import Elementary
|
||||||
|
import ElementaryHTMX
|
||||||
|
import PsychrometricClient
|
||||||
|
import Routes
|
||||||
|
import Styleguide
|
||||||
|
|
||||||
|
struct FeetOfHeadForm: HTML, Sendable {
|
||||||
|
let response: FeetOfHead.Response?
|
||||||
|
|
||||||
|
typealias Key = FeetOfHead.Request.FieldKey
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
FormHeader(label: "Feet of Head", svg: .footprints)
|
||||||
|
|
||||||
|
form(
|
||||||
|
.hx.post(route: .feetOfHead(.index)),
|
||||||
|
.hx.target("#result")
|
||||||
|
) {
|
||||||
|
div(.class("space-y-6")) {
|
||||||
|
div(.class("grid grid-cols-2 gap-4")) {
|
||||||
|
LabeledContent(label: "Pressure (psi)") {
|
||||||
|
Input(id: Key.pressure, placeholder: "Pressure")
|
||||||
|
.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 {
|
||||||
|
"""
|
||||||
|
If water temperature is not supplied, then calculations will be based on 60\(String.fahrenheit) water.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
SubmitButton(label: "Feet of Head")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div(.id("result")) {
|
||||||
|
if let response {
|
||||||
|
FeetOfHeadResponse(response: response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FeetOfHeadResponse: HTML, Sendable {
|
||||||
|
let response: FeetOfHead.Response
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
ResultContainer(reset: .feetOfHead(.index)) {
|
||||||
|
ResultBox {
|
||||||
|
div(.class("grid grid-cols-2")) {
|
||||||
|
VerticalGroup(label: "Feet of Head", value: "\(double: response.feetOfHead)", valueLabel: "ft.")
|
||||||
|
VerticalGroup(
|
||||||
|
label: "Water Density",
|
||||||
|
value: "\(double: response.density.value)",
|
||||||
|
valueLabel: response.density.units.rawValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WarningBox(warnings: response.warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,7 +128,11 @@ struct ViewControllerTests {
|
|||||||
|
|
||||||
// Hydronic system pressure
|
// Hydronic system pressure
|
||||||
.hydronicSystemPressure(.index),
|
.hydronicSystemPressure(.index),
|
||||||
.hydronicSystemPressure(.submit(.init(height: 12, waterTemperature: 60)))
|
.hydronicSystemPressure(.submit(.init(height: 12, waterTemperature: 60))),
|
||||||
|
|
||||||
|
// Feet of head
|
||||||
|
.feetOfHead(.index),
|
||||||
|
.feetOfHead(.submit(.init(pressure: 3.5, waterTemperature: 60)))
|
||||||
|
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<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-footprints">
|
||||||
|
<path d="M4 16v-2.38C4 11.5 2.97 10.5 3 8c.03-2.72 1.49-6 4.5-6C9.37 2 10 3.8 10 5.5c0 3.11-2 5.66-2 8.68V16a2 2 0 1 1-4 0Z"/>
|
||||||
|
<path d="M20 20v-2.38c0-2.12 1.03-3.12 1-5.62-.03-2.72-1.49-6-4.5-6C14.63 6 14 7.8 14 9.5c0 3.11 2 5.66 2 8.68V20a2 2 0 1 0 4 0Z"/>
|
||||||
|
<path d="M16 17h4"/>
|
||||||
|
<path d="M4 13h4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-extrabold">Feet of Head</h2>
|
||||||
|
</div>
|
||||||
|
<form hx-post="/feet-of-head" hx-target="#result">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="pressure" class="block text-sm font-medium mb-2">Pressure (psi)</label>
|
||||||
|
<input id="pressure" placeholder="Pressure" name="pressure" 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">If water temperature is not supplied, then calculations will be based on 60°F 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">Feet of Head</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="result">
|
||||||
|
<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="/feet-of-head" 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">Feet of Head</p>
|
||||||
|
<h3 class="text-3xl font-extrabold">7.95<span class="text-lg ms-2">ft.</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.37<span class="text-lg ms-2">lb/ft³</span></h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="warnings"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<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="/feet-of-head" 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">Feet of Head</p>
|
||||||
|
<h3 class="text-3xl font-extrabold">7.9<span class="text-lg ms-2">ft.</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>
|
||||||
Reference in New Issue
Block a user