feat: Adds psychrometrics calculator.

This commit is contained in:
2025-03-06 13:41:38 -05:00
parent e0e5b10a34
commit ee577003d5
13 changed files with 1386 additions and 7 deletions

View File

@@ -1,6 +1,4 @@
# NOTE: Builds currently fail when building in release mode.
ARG SWIFT_MODE="debug"
ARG SWIFT_MODE="release"
# ================================
# Build image

File diff suppressed because one or more lines are too long

View 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.")
}
}
}

View 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

View File

@@ -4,6 +4,9 @@ import Foundation
import PsychrometricClient
@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
public enum SiteRoute: Equatable, Sendable {
@@ -105,6 +108,7 @@ public extension SiteRoute {
case heatingBalancePoint(HeatingBalancePoint)
case hvacSystemPerformance(HVACSystemPerformance)
case moldRisk(MoldRisk)
case psychrometrics(Self.Psychrometrics)
case roomPressure(RoomPressure)
public static let router = OneOf {
@@ -132,6 +136,9 @@ public extension SiteRoute {
Route(.case(Self.moldRisk)) {
MoldRisk.router
}
Route(.case(Self.psychrometrics)) {
Self.Psychrometrics.router
}
Route(.case(Self.roomPressure)) {
RoomPressure.router
}
@@ -460,6 +467,7 @@ public extension SiteRoute {
}
}
}
}
}

View 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)
}
}

View File

@@ -55,6 +55,12 @@ struct HomePage: HTML, Sendable {
svg: .scale,
route: .heatingBalancePoint(.index)
)
group(
label: "Psychrometrics",
description: Psychrometrics.description,
svg: .droplets,
route: .psychrometrics(.index)
)
}
}

View File

@@ -127,7 +127,6 @@ extension ViewController: DependencyKey {
case let .hvacSystemPerformance(route):
switch route {
case .index:
// let response = try await HVACSystemPerformance.Response.mock()
return request.respond(HVACSystemPerformanceForm(response: nil))
case let .submit(hvacRequest):
let response = try await hvacRequest.respond(logger: request.logger)
@@ -143,6 +142,15 @@ extension ViewController: DependencyKey {
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):
switch route {
case let .index(mode):

View File

@@ -1,6 +1,7 @@
import Elementary
import PsychrometricClient
// FIX: Remove text colors.
struct PsychrometricPropertiesView: HTML {
let properties: PsychrometricProperties

View 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)
}
}

View File

@@ -12,7 +12,7 @@ build-docker platform="linux/arm64":
run:
#!/usr/bin/env zsh
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 .build/browser-dev-sync --ignore-nothing -r '.build/debug/App serve --log debug'

View File

@@ -16,6 +16,7 @@
"tailwindcss": "^4.0.8"
},
"dependencies": {
"@tailwindcss/cli": "^4.0.8"
"@tailwindcss/cli": "^4.0.8",
"browser-sync": "^3.0.3"
}
}

1049
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff