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="debug"
|
||||
ARG SWIFT_MODE="release"
|
||||
|
||||
# ================================
|
||||
# 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
|
||||
@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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
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):
|
||||
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):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Elementary
|
||||
import PsychrometricClient
|
||||
|
||||
// FIX: Remove text colors.
|
||||
struct PsychrometricPropertiesView: HTML {
|
||||
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:
|
||||
#!/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'
|
||||
|
||||
|
||||
@@ -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
1049
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user