feat: Adds psychrometrics calculator.
This commit is contained in:
@@ -1,6 +1,4 @@
|
|||||||
# NOTE: Builds currently fail when building in release mode.
|
ARG SWIFT_MODE="release"
|
||||||
|
|
||||||
ARG SWIFT_MODE="debug"
|
|
||||||
|
|
||||||
# ================================
|
# ================================
|
||||||
# Build image
|
# Build image
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
36
Sources/ApiController/Extensions/Psychrometrics.swift
Normal file
36
Sources/ApiController/Extensions/Psychrometrics.swift
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import Dependencies
|
||||||
|
import Logging
|
||||||
|
import PsychrometricClient
|
||||||
|
import Routes
|
||||||
|
|
||||||
|
public extension Routes.Psychrometrics.Request {
|
||||||
|
|
||||||
|
func respond(logger: Logger) async throws -> Psychrometrics.Response {
|
||||||
|
@Dependency(\.psychrometricClient) var psychrometricClient
|
||||||
|
try validate()
|
||||||
|
let properties = try await psychrometricClient.psychrometricProperties(
|
||||||
|
.dryBulb(.fahrenheit(temperature), relativeHumidity: humidity%, altitude: .feet(altitude ?? 0))
|
||||||
|
)
|
||||||
|
var warnings = [String]()
|
||||||
|
if altitude == nil || altitude == 0 {
|
||||||
|
warnings.append(
|
||||||
|
"Calculations based on altitude of sea level - set project altitude for higher accuracy."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .init(properties: properties, warnings: warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validate() throws {
|
||||||
|
guard temperature > 0 else {
|
||||||
|
throw ValidationError(message: "Temperature should be greater than 0.")
|
||||||
|
}
|
||||||
|
guard humidity > 0, humidity <= 100 else {
|
||||||
|
throw ValidationError(message: "Relative humidity should be greater than 0 and less than or equal to 100.")
|
||||||
|
}
|
||||||
|
if let altitude, altitude < 0 {
|
||||||
|
throw ValidationError(message: "Altitude should be greater than 0.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
106
Sources/Routes/Models/PsychrometricProperties.swift
Normal file
106
Sources/Routes/Models/PsychrometricProperties.swift
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import CasePaths
|
||||||
|
import PsychrometricClient
|
||||||
|
@preconcurrency import URLRouting
|
||||||
|
|
||||||
|
public enum Psychrometrics {
|
||||||
|
|
||||||
|
public static let description = """
|
||||||
|
Calculate the psychrometric properties of air based on temperature and humidity readings.
|
||||||
|
"""
|
||||||
|
public struct Request: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
// public let mode: Mode
|
||||||
|
public let temperature: Double
|
||||||
|
public let humidity: Double
|
||||||
|
public let altitude: Double?
|
||||||
|
|
||||||
|
public init(temperature: Double, humidity: Double, altitude: Double? = nil) {
|
||||||
|
self.temperature = temperature
|
||||||
|
self.humidity = humidity
|
||||||
|
self.altitude = altitude
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FieldKey: String {
|
||||||
|
case temperature
|
||||||
|
case humidity
|
||||||
|
case altitude
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Response: Codable, Equatable, Sendable {
|
||||||
|
public let properties: PsychrometricProperties
|
||||||
|
public let warnings: [String]
|
||||||
|
|
||||||
|
public init(properties: PsychrometricProperties, warnings: [String]) {
|
||||||
|
self.properties = properties
|
||||||
|
self.warnings = warnings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Router
|
||||||
|
|
||||||
|
public extension SiteRoute.View {
|
||||||
|
|
||||||
|
enum Psychrometrics: Equatable, Sendable {
|
||||||
|
|
||||||
|
case index
|
||||||
|
case submit(Routes.Psychrometrics.Request)
|
||||||
|
|
||||||
|
typealias Key = Routes.Psychrometrics.Request.FieldKey
|
||||||
|
static let rootPath = "psychrometric-properties"
|
||||||
|
|
||||||
|
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.temperature) {
|
||||||
|
Double.parser()
|
||||||
|
}
|
||||||
|
Field(Key.humidity) {
|
||||||
|
Double.parser()
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field(Key.altitude) {
|
||||||
|
Double.parser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(Routes.Psychrometrics.Request.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
public extension Psychrometrics.Response {
|
||||||
|
static let mock = Self(
|
||||||
|
properties: .init(
|
||||||
|
absoluteHumidity: 91.4,
|
||||||
|
atmosphericPressure: 0.3,
|
||||||
|
degreeOfSaturation: 0.66,
|
||||||
|
density: 0.07,
|
||||||
|
dewPoint: 64.3,
|
||||||
|
dryBulb: 76,
|
||||||
|
enthalpy: 32.33,
|
||||||
|
grainsOfMoisture: 91.4,
|
||||||
|
humidityRatio: 0.01,
|
||||||
|
relativeHumidity: 67,
|
||||||
|
specificVolume: 13.78,
|
||||||
|
vaporPressure: 0.3,
|
||||||
|
wetBulb: 67.7,
|
||||||
|
units: .imperial
|
||||||
|
),
|
||||||
|
warnings: [
|
||||||
|
"Test warning."
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -4,6 +4,9 @@ import Foundation
|
|||||||
import PsychrometricClient
|
import PsychrometricClient
|
||||||
@preconcurrency import URLRouting
|
@preconcurrency import URLRouting
|
||||||
|
|
||||||
|
// FIX: Move Routers into their respective model files, to reduce the size / complexity of this file
|
||||||
|
// and keep them closer to where changes may need made.
|
||||||
|
|
||||||
// swiftlint:disable type_body_length
|
// swiftlint:disable type_body_length
|
||||||
public enum SiteRoute: Equatable, Sendable {
|
public enum SiteRoute: Equatable, Sendable {
|
||||||
|
|
||||||
@@ -105,6 +108,7 @@ public extension SiteRoute {
|
|||||||
case heatingBalancePoint(HeatingBalancePoint)
|
case heatingBalancePoint(HeatingBalancePoint)
|
||||||
case hvacSystemPerformance(HVACSystemPerformance)
|
case hvacSystemPerformance(HVACSystemPerformance)
|
||||||
case moldRisk(MoldRisk)
|
case moldRisk(MoldRisk)
|
||||||
|
case psychrometrics(Self.Psychrometrics)
|
||||||
case roomPressure(RoomPressure)
|
case roomPressure(RoomPressure)
|
||||||
|
|
||||||
public static let router = OneOf {
|
public static let router = OneOf {
|
||||||
@@ -132,6 +136,9 @@ public extension SiteRoute {
|
|||||||
Route(.case(Self.moldRisk)) {
|
Route(.case(Self.moldRisk)) {
|
||||||
MoldRisk.router
|
MoldRisk.router
|
||||||
}
|
}
|
||||||
|
Route(.case(Self.psychrometrics)) {
|
||||||
|
Self.Psychrometrics.router
|
||||||
|
}
|
||||||
Route(.case(Self.roomPressure)) {
|
Route(.case(Self.roomPressure)) {
|
||||||
RoomPressure.router
|
RoomPressure.router
|
||||||
}
|
}
|
||||||
@@ -460,6 +467,7 @@ public extension SiteRoute {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
Sources/Routes/URLRoutingExtensions.swift
Normal file
40
Sources/Routes/URLRoutingExtensions.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import URLRouting
|
||||||
|
|
||||||
|
// Allow the use of a field key enum as the `name` parameter, to avoid
|
||||||
|
// stringly type name fields.
|
||||||
|
public extension Field {
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
init<Key>(
|
||||||
|
_ name: Key,
|
||||||
|
default defaultValue: Value.Output? = nil,
|
||||||
|
@ParserBuilder<Substring> _ value: () -> Value
|
||||||
|
) where Key: RawRepresentable, Key.RawValue == String {
|
||||||
|
self.init(name.rawValue, default: defaultValue, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
init<Key, C>(
|
||||||
|
_ name: Key,
|
||||||
|
_ value: C,
|
||||||
|
default defaultValue: Value.Output? = nil,
|
||||||
|
) where Key: RawRepresentable, Key.RawValue == String,
|
||||||
|
Value == Parsers.MapConversion<Parsers.ReplaceError<Rest<Substring>>, C>
|
||||||
|
{
|
||||||
|
self.init(name.rawValue, value, default: defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
init<Key>(
|
||||||
|
_ name: Key,
|
||||||
|
default defaultValue: Value.Output? = nil
|
||||||
|
)
|
||||||
|
where
|
||||||
|
Key: RawRepresentable, Key.RawValue == String,
|
||||||
|
Value == Parsers.MapConversion<
|
||||||
|
Parsers.ReplaceError<Rest<Substring>>, Conversions.SubstringToString
|
||||||
|
>
|
||||||
|
{
|
||||||
|
self.init(name.rawValue, default: defaultValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,12 @@ struct HomePage: HTML, Sendable {
|
|||||||
svg: .scale,
|
svg: .scale,
|
||||||
route: .heatingBalancePoint(.index)
|
route: .heatingBalancePoint(.index)
|
||||||
)
|
)
|
||||||
|
group(
|
||||||
|
label: "Psychrometrics",
|
||||||
|
description: Psychrometrics.description,
|
||||||
|
svg: .droplets,
|
||||||
|
route: .psychrometrics(.index)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,6 @@ extension ViewController: DependencyKey {
|
|||||||
case let .hvacSystemPerformance(route):
|
case let .hvacSystemPerformance(route):
|
||||||
switch route {
|
switch route {
|
||||||
case .index:
|
case .index:
|
||||||
// let response = try await HVACSystemPerformance.Response.mock()
|
|
||||||
return request.respond(HVACSystemPerformanceForm(response: nil))
|
return request.respond(HVACSystemPerformanceForm(response: nil))
|
||||||
case let .submit(hvacRequest):
|
case let .submit(hvacRequest):
|
||||||
let response = try await hvacRequest.respond(logger: request.logger)
|
let response = try await hvacRequest.respond(logger: request.logger)
|
||||||
@@ -143,6 +142,15 @@ extension ViewController: DependencyKey {
|
|||||||
return MoldRiskResponse(response: response)
|
return MoldRiskResponse(response: response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case let .psychrometrics(route):
|
||||||
|
switch route {
|
||||||
|
case .index:
|
||||||
|
return request.respond(PsychrometricsForm(response: .mock))
|
||||||
|
case let .submit(request):
|
||||||
|
let response = try await request.respond(logger: logger)
|
||||||
|
return PsychrometricsResponse(response: response)
|
||||||
|
}
|
||||||
|
|
||||||
case let .roomPressure(route):
|
case let .roomPressure(route):
|
||||||
switch route {
|
switch route {
|
||||||
case let .index(mode):
|
case let .index(mode):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Elementary
|
import Elementary
|
||||||
import PsychrometricClient
|
import PsychrometricClient
|
||||||
|
|
||||||
|
// FIX: Remove text colors.
|
||||||
struct PsychrometricPropertiesView: HTML {
|
struct PsychrometricPropertiesView: HTML {
|
||||||
let properties: PsychrometricProperties
|
let properties: PsychrometricProperties
|
||||||
|
|
||||||
|
|||||||
126
Sources/ViewController/Views/Psychrometrics.swift
Normal file
126
Sources/ViewController/Views/Psychrometrics.swift
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import Elementary
|
||||||
|
import ElementaryHTMX
|
||||||
|
import PsychrometricClient
|
||||||
|
import Routes
|
||||||
|
import Styleguide
|
||||||
|
|
||||||
|
struct PsychrometricsForm: HTML, Sendable {
|
||||||
|
|
||||||
|
// let mode: Psychrometrics.Mode
|
||||||
|
let response: Psychrometrics.Response?
|
||||||
|
|
||||||
|
init(response: Psychrometrics.Response? = nil) {
|
||||||
|
self.response = response
|
||||||
|
}
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
FormHeader(label: "Psychrometric Properties", svg: .droplets)
|
||||||
|
|
||||||
|
form(
|
||||||
|
.hx.post(route: .psychrometrics(.index)),
|
||||||
|
.hx.target("#result")
|
||||||
|
) {
|
||||||
|
div(.class("space-y-6")) {
|
||||||
|
div(.class("grid grid-cols-1 lg:grid-cols-2 gap-4")) {
|
||||||
|
LabeledContent(label: "Temperature (°F)") {
|
||||||
|
Input(id: Psychrometrics.Request.FieldKey.temperature.rawValue, placeholder: "Dry bulb temperature")
|
||||||
|
.attributes(.type(.number), .min("0.1"), .step("0.1"), .autofocus, .required)
|
||||||
|
}
|
||||||
|
LabeledContent(label: "Relative Humidity (%)") {
|
||||||
|
Input(id: Psychrometrics.Request.FieldKey.humidity.rawValue, placeholder: "Relative humidity")
|
||||||
|
.attributes(.type(.number), .min("0.1"), .step("0.1"), .max("100"), .required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledContent(label: "Altitude (ft.)") {
|
||||||
|
Input(id: Psychrometrics.Request.FieldKey.altitude.rawValue, placeholder: "Altitude (optional)")
|
||||||
|
.attributes(.type(.number), .min("0.1"), .step("0.1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
SubmitButton(label: "Calculate Psychrometrics")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div(.id("result")) {
|
||||||
|
if let response {
|
||||||
|
PsychrometricsResponse(response: response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PsychrometricsResponse: HTML, Sendable {
|
||||||
|
let response: Psychrometrics.Response
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
ResultContainer(reset: .psychrometrics(.index)) {
|
||||||
|
div(.class("w-full rounded-lg shadow-lg bg-blue-100 border border-blue-600 text-blue-600 p-6")) {
|
||||||
|
div(.class("flex mb-8")) {
|
||||||
|
SVG(.droplets, color: "text-blue-600")
|
||||||
|
h3(.class("text-xl px-2 font-semibold")) { "Psychrometrics" }
|
||||||
|
}
|
||||||
|
div(.class("grid grid-cols-3 gap-4")) {
|
||||||
|
div(.class("rounded-lg bg-purple-200 border border-purple-600 text-purple-600")) {
|
||||||
|
VerticalGroup(
|
||||||
|
label: "Dew Point",
|
||||||
|
value: "\(double: response.properties.dewPoint.value, fractionDigits: 1)",
|
||||||
|
valueLabel: response.properties.dewPoint.units.symbol
|
||||||
|
)
|
||||||
|
.attributes(.class("my-8"))
|
||||||
|
}
|
||||||
|
div(.class("rounded-lg bg-orange-200 border border-orange-600 text-orange-600")) {
|
||||||
|
VerticalGroup(
|
||||||
|
label: "Enthalpy",
|
||||||
|
value: "\(double: response.properties.enthalpy.value, fractionDigits: 1)",
|
||||||
|
valueLabel: response.properties.enthalpy.units.rawValue
|
||||||
|
)
|
||||||
|
.attributes(.class("my-8"))
|
||||||
|
}
|
||||||
|
div(.class("rounded-lg bg-green-200 border border-green-600 text-green-600")) {
|
||||||
|
VerticalGroup(
|
||||||
|
label: "Wet Bulb",
|
||||||
|
value: "\(double: response.properties.wetBulb.value, fractionDigits: 1)",
|
||||||
|
valueLabel: response.properties.wetBulb.units.symbol
|
||||||
|
)
|
||||||
|
.attributes(.class("my-8"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div(.class("mt-8")) {
|
||||||
|
h4(.class("text-lg font-semibold")) { "Other Properties" }
|
||||||
|
div(.class("rounded-lg border border-blue-300")) {
|
||||||
|
displayProperty("Density", \.density.rawValue)
|
||||||
|
displayProperty("Vapor Pressure", \.vaporPressure.rawValue)
|
||||||
|
displayProperty("Specific Volume", response.properties.specificVolume.rawValue)
|
||||||
|
displayProperty("Absolute Humidity", \.absoluteHumidity)
|
||||||
|
displayProperty("Humidity Ratio", response.properties.humidityRatio.value)
|
||||||
|
displayProperty("Degree of Saturation", response.properties.degreeOfSaturation.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WarningBox(warnings: response.warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func displayProperty(_ label: String, _ value: Double, _ symbol: String? = nil) -> some HTML {
|
||||||
|
let symbol = "\(symbol == nil ? "" : " \(symbol!)")"
|
||||||
|
|
||||||
|
return div(.class("flex items-center justify-between border-b border-blue-300 p-2")) {
|
||||||
|
span(.class("font-semibold")) { "\(label): " }
|
||||||
|
span(.class("font-light")) {
|
||||||
|
"\(double: value, fractionDigits: 2)\(symbol)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func displayProperty<N: NumberWithUnitOfMeasure>(
|
||||||
|
_ label: String,
|
||||||
|
_ keyPath: KeyPath<PsychrometricProperties, N>
|
||||||
|
) -> some HTML where N.Number == Double, N.Units: RawRepresentable, N.Units.RawValue == String {
|
||||||
|
let property = response.properties[keyPath: keyPath]
|
||||||
|
return displayProperty(label, property.rawValue, property.units.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
2
justfile
2
justfile
@@ -12,7 +12,7 @@ build-docker platform="linux/arm64":
|
|||||||
run:
|
run:
|
||||||
#!/usr/bin/env zsh
|
#!/usr/bin/env zsh
|
||||||
touch .build/browser-dev-sync
|
touch .build/browser-dev-sync
|
||||||
browser-sync start -p localhost:8080 --ws &
|
browser-sync start -p localhost:8080 --watch --files '.build/browser-dev-sync' &
|
||||||
watchexec -w Sources -e .swift -r 'swift build --product App && touch .build/browser-dev-sync' &
|
watchexec -w Sources -e .swift -r 'swift build --product App && touch .build/browser-dev-sync' &
|
||||||
watchexec -w .build/browser-dev-sync --ignore-nothing -r '.build/debug/App serve --log debug'
|
watchexec -w .build/browser-dev-sync --ignore-nothing -r '.build/debug/App serve --log debug'
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"tailwindcss": "^4.0.8"
|
"tailwindcss": "^4.0.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/cli": "^4.0.8"
|
"@tailwindcss/cli": "^4.0.8",
|
||||||
|
"browser-sync": "^3.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1049
pnpm-lock.yaml
generated
1049
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user