Compare commits
3 Commits
9da4149391
...
6c31a9db09
| Author | SHA1 | Date | |
|---|---|---|---|
|
6c31a9db09
|
|||
|
d22beb9375
|
|||
|
19a51598e4
|
@@ -9,7 +9,6 @@ let package = Package(
|
|||||||
products: [
|
products: [
|
||||||
.executable(name: "App", targets: ["App"]),
|
.executable(name: "App", targets: ["App"]),
|
||||||
.library(name: "ApiController", targets: ["ApiController"]),
|
.library(name: "ApiController", targets: ["ApiController"]),
|
||||||
.library(name: "ClimateZoneClient", targets: ["ClimateZoneClient"]),
|
|
||||||
.library(name: "CoreModels", targets: ["CoreModels"]),
|
.library(name: "CoreModels", targets: ["CoreModels"]),
|
||||||
.library(name: "LocationClient", targets: ["LocationClient"]),
|
.library(name: "LocationClient", targets: ["LocationClient"]),
|
||||||
.library(name: "Routes", targets: ["Routes"]),
|
.library(name: "Routes", targets: ["Routes"]),
|
||||||
@@ -62,22 +61,6 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
swiftSettings: swiftSettings
|
swiftSettings: swiftSettings
|
||||||
),
|
),
|
||||||
.target(
|
|
||||||
name: "LocationClient",
|
|
||||||
dependencies: [
|
|
||||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
|
||||||
.product(name: "DependenciesMacros", package: "swift-dependencies")
|
|
||||||
],
|
|
||||||
swiftSettings: swiftSettings
|
|
||||||
),
|
|
||||||
.target(
|
|
||||||
name: "ClimateZoneClient",
|
|
||||||
dependencies: [
|
|
||||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
|
||||||
.product(name: "DependenciesMacros", package: "swift-dependencies")
|
|
||||||
],
|
|
||||||
swiftSettings: swiftSettings
|
|
||||||
),
|
|
||||||
.target(
|
.target(
|
||||||
name: "CoreModels",
|
name: "CoreModels",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
@@ -94,6 +77,7 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "LocationClient",
|
name: "LocationClient",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
"CoreModels",
|
||||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
.product(name: "DependenciesMacros", package: "swift-dependencies")
|
.product(name: "DependenciesMacros", package: "swift-dependencies")
|
||||||
],
|
],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
158
Sources/ApiController/Extensions/HeatingBalancePoint.swift
Normal file
158
Sources/ApiController/Extensions/HeatingBalancePoint.swift
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import CoreModels
|
||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
import Routes
|
||||||
|
|
||||||
|
public extension HeatingBalancePoint.Request {
|
||||||
|
|
||||||
|
func respond(logger: Logger) async throws -> HeatingBalancePoint.Response {
|
||||||
|
switch self {
|
||||||
|
case let .thermal(request):
|
||||||
|
logger.debug("Calculating thermal balance point: \(request)")
|
||||||
|
return try await request.respond(logger: logger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension HeatingBalancePoint.Request.Thermal {
|
||||||
|
|
||||||
|
static let heatPumpDeratingFactor = 0.014
|
||||||
|
|
||||||
|
func respond(logger: Logger) async throws -> HeatingBalancePoint.Response {
|
||||||
|
try validate()
|
||||||
|
let (at47, at17) = getCapacities()
|
||||||
|
let heatingDesignTemperature = getHeatingDesignTemperature()
|
||||||
|
let buildingHeatLoss = getBuildingHeatLoss(designTemperature: heatingDesignTemperature)
|
||||||
|
|
||||||
|
let balancePoint = await thermalBalancePoint(
|
||||||
|
heatLoss: buildingHeatLoss,
|
||||||
|
at47: at47,
|
||||||
|
at17: at17,
|
||||||
|
designTemperature: heatingDesignTemperature
|
||||||
|
)
|
||||||
|
|
||||||
|
let warnings = getWarnings()
|
||||||
|
|
||||||
|
return .thermal(.init(
|
||||||
|
capacityAt47: at47,
|
||||||
|
capacityAt17: at17,
|
||||||
|
balancePointTemperature: balancePoint,
|
||||||
|
heatLoss: buildingHeatLoss,
|
||||||
|
heatLossMode: self.buildingHeatLoss.mode,
|
||||||
|
heatingDesignTemperature: heatingDesignTemperature,
|
||||||
|
warnings: warnings
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWarnings() -> [String] {
|
||||||
|
var warnings = [String]()
|
||||||
|
if capacityAt17 == nil || capacityAt47 == nil {
|
||||||
|
warnings.append(
|
||||||
|
"Heat pump capacities are estimated based on system size - include actual capacities for higher accuracy."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if case .estimated = buildingHeatLoss {
|
||||||
|
warnings.append(
|
||||||
|
"Building heat loss is estimated based on climate zone - include actual heat loss for higher accuracy"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if heatingDesignTemperature == nil {
|
||||||
|
warnings.append(
|
||||||
|
"""
|
||||||
|
Heating outdoor design temperature is based on average for climate zone - include outdoor design temperature
|
||||||
|
for higher accuracy.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBuildingHeatLoss(designTemperature: Double) -> Double {
|
||||||
|
switch buildingHeatLoss {
|
||||||
|
case let .known(btu: btu): return btu
|
||||||
|
case let .estimated(squareFeet: squareFeet):
|
||||||
|
return squareFeet * climateZone!.averageHeatLossPerSquareFoot * (70 - designTemperature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCapacities() -> (at47: Double, at17: Double) {
|
||||||
|
let at47 = capacityAt47 ?? systemSize * 12000
|
||||||
|
let at17 = capacityAt17 ?? at47 * (1 - Self.heatPumpDeratingFactor * (47 - 17))
|
||||||
|
return (at47, at17)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHeatingDesignTemperature() -> Double {
|
||||||
|
guard let heatingDesignTemperature else {
|
||||||
|
return climateZone!.averageHeatingDesignTemperature
|
||||||
|
}
|
||||||
|
return heatingDesignTemperature
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate() throws {
|
||||||
|
guard systemSize > 0 else {
|
||||||
|
throw ValidationError(message: "System size should be greater than 0.")
|
||||||
|
}
|
||||||
|
switch buildingHeatLoss {
|
||||||
|
case let .known(btu: btu):
|
||||||
|
guard btu > 0 else {
|
||||||
|
throw ValidationError(message: "Building heat loss btu's should be greater than 0.")
|
||||||
|
}
|
||||||
|
case let .estimated(squareFeet: squareFeet):
|
||||||
|
guard squareFeet > 0 else {
|
||||||
|
throw ValidationError(message: "Building squareFeet should be greater than 0.")
|
||||||
|
}
|
||||||
|
guard climateZone != nil else {
|
||||||
|
throw ValidationError(message: "Climate zone is required when estimating heat loss.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let capacityAt47 {
|
||||||
|
guard capacityAt47 > 0 else {
|
||||||
|
throw ValidationError(message: "Heat pump capacity @ 47 should be greater than 0.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let capacityAt17 {
|
||||||
|
guard capacityAt17 > 0 else {
|
||||||
|
throw ValidationError(message: "Heat pump capacity @ 17 should be greater than 0.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ClimateZone {
|
||||||
|
|
||||||
|
var averageHeatLossPerSquareFoot: Double {
|
||||||
|
switch self {
|
||||||
|
case .one: return 0.08
|
||||||
|
case .two: return 0.1
|
||||||
|
case .three: return 0.125
|
||||||
|
case .four: return 0.15
|
||||||
|
case .five: return 0.19
|
||||||
|
case .six: return 0.25
|
||||||
|
case .seven: return 0.3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var averageHeatingDesignTemperature: Double {
|
||||||
|
switch self {
|
||||||
|
case .one: return 45
|
||||||
|
case .two: return 35
|
||||||
|
case .three: return 27
|
||||||
|
case .four: return 17
|
||||||
|
case .five: return 7
|
||||||
|
case .six: return -5
|
||||||
|
case .seven: return -15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func thermalBalancePoint(
|
||||||
|
heatLoss: Double,
|
||||||
|
at47: Double,
|
||||||
|
at17: Double,
|
||||||
|
designTemperature: Double
|
||||||
|
) async -> Double {
|
||||||
|
(30.0 * (((designTemperature - 65.0) * at47) + (65.0 * heatLoss))
|
||||||
|
- ((designTemperature - 65.0) * (at47 - at17) * 47.0))
|
||||||
|
/ ((30.0 * heatLoss) - ((designTemperature - 65.0) * (at47 - at17)))
|
||||||
|
}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum ClimateZone {
|
public enum ClimateZone: String, CaseIterable, Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
case one = "CZ1"
|
||||||
|
case two = "CZ2"
|
||||||
|
case three = "CZ3"
|
||||||
|
case four = "CZ4"
|
||||||
|
case five = "CZ5"
|
||||||
|
case six = "CZ6"
|
||||||
|
case seven = "CZ7"
|
||||||
|
|
||||||
|
public var label: String { rawValue }
|
||||||
|
|
||||||
public enum ZoneType: String, CaseIterable, Codable, Equatable, Sendable {
|
public enum ZoneType: String, CaseIterable, Codable, Equatable, Sendable {
|
||||||
// NOTE: Keep in this order.
|
// NOTE: Keep in this order.
|
||||||
@@ -35,33 +45,33 @@ public enum ClimateZone {
|
|||||||
public var label: String {
|
public var label: String {
|
||||||
return "\(self == .hotHumid ? "Hot Humid" : rawValue.capitalized) (\(zoneIdentifiers.joined(separator: ", ")))"
|
return "\(self == .hotHumid ? "Hot Humid" : rawValue.capitalized) (\(zoneIdentifiers.joined(separator: ", ")))"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Represents climate zone identifiers.
|
/// Represents climate zone identifiers.
|
||||||
public enum ZoneIdentifier: String, CaseIterable, Codable, Equatable, Sendable {
|
public enum ZoneIdentifier: String, CaseIterable, Codable, Equatable, Sendable {
|
||||||
// A zones (hotHumid)
|
// A zones (hotHumid)
|
||||||
case oneA = "1A"
|
case oneA = "1A"
|
||||||
case twoA = "2A"
|
case twoA = "2A"
|
||||||
// A zones (moist)
|
// A zones (moist)
|
||||||
case threeA = "3A"
|
case threeA = "3A"
|
||||||
case fourA = "4A"
|
case fourA = "4A"
|
||||||
case fiveA = "5A"
|
case fiveA = "5A"
|
||||||
case sixA = "6A"
|
case sixA = "6A"
|
||||||
case sevenA = "7A"
|
case sevenA = "7A"
|
||||||
|
|
||||||
// B zones (dry)
|
// B zones (dry)
|
||||||
case twoB = "2B"
|
case twoB = "2B"
|
||||||
case threeB = "3B"
|
case threeB = "3B"
|
||||||
case fourB = "4B"
|
case fourB = "4B"
|
||||||
case fiveB = "5B"
|
case fiveB = "5B"
|
||||||
case sixB = "6B"
|
case sixB = "6B"
|
||||||
case sevenB = "7B"
|
case sevenB = "7B"
|
||||||
|
|
||||||
// C zones (marine)
|
// C zones (marine)
|
||||||
case threeC = "3C"
|
case threeC = "3C"
|
||||||
case fourC = "4C"
|
case fourC = "4C"
|
||||||
|
|
||||||
public var label: String { rawValue }
|
public var label: String { rawValue }
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@_exported import CoreModels
|
||||||
import Dependencies
|
import Dependencies
|
||||||
import DependenciesMacros
|
import DependenciesMacros
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -11,56 +12,135 @@ public extension DependencyValues {
|
|||||||
|
|
||||||
@DependencyClient
|
@DependencyClient
|
||||||
public struct LocationClient: Sendable {
|
public struct LocationClient: Sendable {
|
||||||
public var search: @Sendable (Int) async throws -> [Response]
|
|
||||||
|
|
||||||
// TODO: Add ClimateZone.ZoneIdentifier??
|
/// Estimate the climate zone by the heating design temperature and
|
||||||
public struct Response: Codable, Equatable, Sendable {
|
/// the state code (ex. "OH").
|
||||||
|
public var estimatedClimateZone:
|
||||||
|
@Sendable (_ heatingTemperature: Double, _ stateCode: String) async throws -> ClimateZone
|
||||||
|
|
||||||
public let city: String
|
/// Estimate the design temperatures based on location's coordinates.
|
||||||
public let latitude: String
|
public var estimatedDesignTemperatures: @Sendable (Coordinates) async throws -> DesignTemperatures
|
||||||
public let longitude: String
|
|
||||||
public let zipCode: String
|
|
||||||
public let state: String
|
|
||||||
public let stateCode: String
|
|
||||||
public let county: String
|
|
||||||
|
|
||||||
public init(
|
/// Get location details from a zip code.
|
||||||
city: String,
|
public var search: @Sendable (_ zipCode: Int) async throws -> [Location]
|
||||||
latitude: String,
|
|
||||||
longitude: String,
|
|
||||||
zipCode: String,
|
|
||||||
state: String,
|
|
||||||
stateCode: String,
|
|
||||||
county: String
|
|
||||||
) {
|
|
||||||
self.city = city
|
|
||||||
self.latitude = latitude
|
|
||||||
self.longitude = longitude
|
|
||||||
self.zipCode = zipCode
|
|
||||||
self.state = state
|
|
||||||
self.stateCode = stateCode
|
|
||||||
self.county = county
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
public func estimateDesignTemperaturesAndClimateZone(
|
||||||
case city
|
coordinates: Coordinates,
|
||||||
case latitude
|
stateCode: String
|
||||||
case longitude
|
) async throws -> (DesignTemperatures, ClimateZone) {
|
||||||
case zipCode = "postal_code"
|
let designTemperatures = try await estimatedDesignTemperatures(coordinates)
|
||||||
case state
|
let climateZone = try await estimatedClimateZone(
|
||||||
case stateCode = "state_code"
|
heatingTemperature: designTemperatures.heating,
|
||||||
case county = "province"
|
stateCode: stateCode
|
||||||
}
|
)
|
||||||
|
return (designTemperatures, climateZone)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DecodingError: Error {}
|
||||||
|
|
||||||
extension LocationClient: TestDependencyKey {
|
extension LocationClient: TestDependencyKey {
|
||||||
public static let testValue: LocationClient = Self()
|
public static let testValue: LocationClient = Self()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Intermediate response returned from 'https://app.zipcodebase.com'
|
||||||
|
private struct IntermediateResponse: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
let city: String
|
||||||
|
let latitude: String
|
||||||
|
let longitude: String
|
||||||
|
let zipCode: String
|
||||||
|
let state: String
|
||||||
|
let stateCode: String
|
||||||
|
let county: String
|
||||||
|
|
||||||
|
func toLocation() throws -> Location {
|
||||||
|
guard let longitude = Double(longitude), let latitude = Double(latitude) else {
|
||||||
|
throw DecodingError()
|
||||||
|
}
|
||||||
|
return .init(
|
||||||
|
city: city,
|
||||||
|
state: state,
|
||||||
|
stateCode: stateCode,
|
||||||
|
zipCode: zipCode,
|
||||||
|
county: county,
|
||||||
|
coordinates: .init(latitude: latitude, longitude: longitude)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case city
|
||||||
|
case latitude
|
||||||
|
case longitude
|
||||||
|
case zipCode = "postal_code"
|
||||||
|
case state
|
||||||
|
case stateCode = "state_code"
|
||||||
|
case county = "province"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Need to add climate zone 7 (-17).
|
||||||
|
private func estimatedHeatingTemperature(_ coordinates: Coordinates) -> Double {
|
||||||
|
let latitude = coordinates.latitude
|
||||||
|
let longitude = coordinates.longitude
|
||||||
|
|
||||||
|
// Gulf coast region
|
||||||
|
if latitude < 31, longitude > -95 && longitude < -89 {
|
||||||
|
return 35
|
||||||
|
}
|
||||||
|
|
||||||
|
// Florida
|
||||||
|
if latitude < 31, longitude > -87.5 && longitude < -80 {
|
||||||
|
return 40
|
||||||
|
}
|
||||||
|
|
||||||
|
let absLat = abs(latitude)
|
||||||
|
if absLat < 30 { return 35 }
|
||||||
|
if absLat < 33 { return 30 }
|
||||||
|
if absLat < 36 { return 25 }
|
||||||
|
if absLat < 40 { return 15 }
|
||||||
|
if absLat < 45 { return 5 }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Need to add climate zone 7 (89).
|
||||||
|
private func estimatedCoolingTemperature(_ coordinates: Coordinates) -> Double {
|
||||||
|
let latitude = coordinates.latitude
|
||||||
|
let longitude = coordinates.longitude
|
||||||
|
|
||||||
|
// Gulf coast and Florida
|
||||||
|
if latitude < 31, longitude > -95 && longitude < -80 {
|
||||||
|
return 95
|
||||||
|
}
|
||||||
|
|
||||||
|
let absLat = abs(latitude)
|
||||||
|
if absLat < 30 { return 95 }
|
||||||
|
if absLat < 33 { return 92 }
|
||||||
|
if absLat < 36 { return 90 }
|
||||||
|
if absLat < 40 { return 90 }
|
||||||
|
if absLat < 45 { return 88 }
|
||||||
|
return 85
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Need to add climate zone 7.
|
||||||
|
private func determineClimateZone(heatingTemperature: Double, stateCode: String) -> ClimateZone {
|
||||||
|
let hotHumidStates = ["FL", "LA", "TX", "MS", "AL", "GA", "SC"]
|
||||||
|
if hotHumidStates.contains(stateCode.uppercased()) {
|
||||||
|
return .two
|
||||||
|
}
|
||||||
|
|
||||||
|
if heatingTemperature >= 45 { return .one }
|
||||||
|
if heatingTemperature >= 35 { return .two }
|
||||||
|
if heatingTemperature >= 25 { return .three }
|
||||||
|
if heatingTemperature >= 15 { return .four }
|
||||||
|
if heatingTemperature >= 5 { return .five }
|
||||||
|
return .six
|
||||||
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|
||||||
public extension LocationClient.Response {
|
extension IntermediateResponse {
|
||||||
static let mock = Self(
|
static let mock = Self(
|
||||||
city: "Monroe",
|
city: "Monroe",
|
||||||
latitude: "39.4413000",
|
latitude: "39.4413000",
|
||||||
|
|||||||
118
Sources/Routes/Models/HeatingBalancePoint.swift
Normal file
118
Sources/Routes/Models/HeatingBalancePoint.swift
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import CoreModels
|
||||||
|
|
||||||
|
public enum HeatingBalancePoint {
|
||||||
|
|
||||||
|
public static let description: String = """
|
||||||
|
Calculate the heating balance point.
|
||||||
|
"""
|
||||||
|
|
||||||
|
public enum Mode: String, CaseIterable, Codable, Equatable, Sendable {
|
||||||
|
case economic
|
||||||
|
case thermal
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Request: Codable, Equatable, Sendable {
|
||||||
|
case thermal(Thermal)
|
||||||
|
|
||||||
|
public struct Thermal: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let systemSize: Double
|
||||||
|
public let capacityAt47: Double?
|
||||||
|
public let capacityAt17: Double?
|
||||||
|
public let heatingDesignTemperature: Double?
|
||||||
|
public let buildingHeatLoss: HeatingBalancePoint.HeatLoss
|
||||||
|
public let climateZone: ClimateZone?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
systemSize: Double,
|
||||||
|
capacityAt47: Double? = nil,
|
||||||
|
capacityAt17: Double? = nil,
|
||||||
|
heatingDesignTemperature: Double? = nil,
|
||||||
|
buildingHeatLoss: HeatingBalancePoint.HeatLoss,
|
||||||
|
climateZone: ClimateZone? = nil
|
||||||
|
) {
|
||||||
|
self.systemSize = systemSize
|
||||||
|
self.capacityAt47 = capacityAt47
|
||||||
|
self.capacityAt17 = capacityAt17
|
||||||
|
self.heatingDesignTemperature = heatingDesignTemperature
|
||||||
|
self.buildingHeatLoss = buildingHeatLoss
|
||||||
|
self.climateZone = climateZone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Response: Codable, Equatable, Sendable {
|
||||||
|
case thermal(Thermal)
|
||||||
|
|
||||||
|
public struct Thermal: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let capacityAt47: Double
|
||||||
|
public let capacityAt17: Double
|
||||||
|
public let balancePointTemperature: Double
|
||||||
|
public let heatLoss: Double
|
||||||
|
public let heatLossMode: HeatLoss.Mode
|
||||||
|
public let heatingDesignTemperature: Double
|
||||||
|
public let warnings: [String]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
capacityAt47: Double,
|
||||||
|
capacityAt17: Double,
|
||||||
|
balancePointTemperature: Double,
|
||||||
|
heatLoss: Double,
|
||||||
|
heatLossMode: HeatLoss.Mode,
|
||||||
|
heatingDesignTemperature: Double,
|
||||||
|
warnings: [String]
|
||||||
|
) {
|
||||||
|
self.capacityAt47 = capacityAt47
|
||||||
|
self.capacityAt17 = capacityAt17
|
||||||
|
self.balancePointTemperature = balancePointTemperature
|
||||||
|
self.heatLoss = heatLoss
|
||||||
|
self.heatLossMode = heatLossMode
|
||||||
|
self.heatingDesignTemperature = heatingDesignTemperature
|
||||||
|
self.warnings = warnings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum HeatLoss: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public enum Mode: String, CaseIterable, Codable, Equatable, Sendable {
|
||||||
|
case estimated
|
||||||
|
case known
|
||||||
|
}
|
||||||
|
|
||||||
|
case known(btu: Double)
|
||||||
|
case estimated(squareFeet: Double)
|
||||||
|
|
||||||
|
public var mode: Mode {
|
||||||
|
switch self {
|
||||||
|
case .known: return .known
|
||||||
|
case .estimated: return .estimated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
public extension HeatingBalancePoint.Response {
|
||||||
|
|
||||||
|
static func mock(mode: HeatingBalancePoint.Mode) -> Self {
|
||||||
|
switch mode {
|
||||||
|
case .economic:
|
||||||
|
fatalError()
|
||||||
|
case .thermal:
|
||||||
|
return .thermal(.init(
|
||||||
|
capacityAt47: 24600,
|
||||||
|
capacityAt17: 15100,
|
||||||
|
balancePointTemperature: 38.5,
|
||||||
|
heatLoss: 49667,
|
||||||
|
heatLossMode: .known,
|
||||||
|
heatingDesignTemperature: 5,
|
||||||
|
warnings: [
|
||||||
|
"Design temperature is estimated based on climate zone."
|
||||||
|
]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -4,6 +4,7 @@ import Foundation
|
|||||||
import PsychrometricClient
|
import PsychrometricClient
|
||||||
@preconcurrency import URLRouting
|
@preconcurrency import URLRouting
|
||||||
|
|
||||||
|
// swiftlint:disable type_body_length
|
||||||
public enum SiteRoute: Equatable, Sendable {
|
public enum SiteRoute: Equatable, Sendable {
|
||||||
|
|
||||||
case api(Api)
|
case api(Api)
|
||||||
@@ -101,6 +102,7 @@ public extension SiteRoute {
|
|||||||
case capacitor(Capacitor)
|
case capacitor(Capacitor)
|
||||||
case dehumidifierSize(DehumidifierSize)
|
case dehumidifierSize(DehumidifierSize)
|
||||||
case filterPressureDrop(FilterPressureDrop)
|
case filterPressureDrop(FilterPressureDrop)
|
||||||
|
case heatingBalancePoint(HeatingBalancePoint)
|
||||||
case hvacSystemPerformance(HVACSystemPerformance)
|
case hvacSystemPerformance(HVACSystemPerformance)
|
||||||
case moldRisk(MoldRisk)
|
case moldRisk(MoldRisk)
|
||||||
case roomPressure(RoomPressure)
|
case roomPressure(RoomPressure)
|
||||||
@@ -121,6 +123,9 @@ public extension SiteRoute {
|
|||||||
Route(.case(Self.filterPressureDrop)) {
|
Route(.case(Self.filterPressureDrop)) {
|
||||||
FilterPressureDrop.router
|
FilterPressureDrop.router
|
||||||
}
|
}
|
||||||
|
Route(.case(Self.heatingBalancePoint)) {
|
||||||
|
HeatingBalancePoint.router
|
||||||
|
}
|
||||||
Route(.case(Self.hvacSystemPerformance)) {
|
Route(.case(Self.hvacSystemPerformance)) {
|
||||||
HVACSystemPerformance.router
|
HVACSystemPerformance.router
|
||||||
}
|
}
|
||||||
@@ -280,6 +285,60 @@ public extension SiteRoute {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum HeatingBalancePoint: Equatable, Sendable {
|
||||||
|
case index(
|
||||||
|
mode: Routes.HeatingBalancePoint.Mode? = nil,
|
||||||
|
heatLossMode: Routes.HeatingBalancePoint.HeatLoss.Mode? = nil
|
||||||
|
)
|
||||||
|
case heatLossFields(mode: Routes.HeatingBalancePoint.HeatLoss.Mode)
|
||||||
|
case submit(Routes.HeatingBalancePoint.Request)
|
||||||
|
|
||||||
|
public static var index: Self { index() }
|
||||||
|
|
||||||
|
static let rootPath = "balance-point"
|
||||||
|
|
||||||
|
public static let router = OneOf {
|
||||||
|
Route(.case(Self.index)) {
|
||||||
|
Path { rootPath }
|
||||||
|
Method.get
|
||||||
|
Query {
|
||||||
|
Optionally { Field("mode") { Routes.HeatingBalancePoint.Mode.parser() } }
|
||||||
|
Optionally { Field("heatLossMode") { Routes.HeatingBalancePoint.HeatLoss.Mode.parser() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Route(.case(Self.heatLossFields)) {
|
||||||
|
Path { rootPath; "heat-loss" }
|
||||||
|
Method.get
|
||||||
|
Query {
|
||||||
|
Field("mode") { Routes.HeatingBalancePoint.HeatLoss.Mode.parser() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Route(.case(Self.submit)) {
|
||||||
|
Path { rootPath }
|
||||||
|
Method.post
|
||||||
|
Body {
|
||||||
|
OneOf {
|
||||||
|
FormData {
|
||||||
|
Field("systemSize") { Double.parser() }
|
||||||
|
Optionally { Field("capacityAt47") { Double.parser() } }
|
||||||
|
Optionally { Field("capacityAt17") { Double.parser() } }
|
||||||
|
Optionally { Field("heatingDesignTemperature") { Double.parser() } }
|
||||||
|
OneOf {
|
||||||
|
Field("knownHeatLoss") { Double.parser() }
|
||||||
|
.map(.case(Routes.HeatingBalancePoint.HeatLoss.known))
|
||||||
|
Field("simplifiedHeatLoss") { Double.parser() }
|
||||||
|
.map(.case(Routes.HeatingBalancePoint.HeatLoss.estimated))
|
||||||
|
}
|
||||||
|
Optionally { Field("climateZone") { ClimateZone.parser() } }
|
||||||
|
}
|
||||||
|
.map(.memberwise(Routes.HeatingBalancePoint.Request.Thermal.init))
|
||||||
|
.map(.case(Routes.HeatingBalancePoint.Request.thermal))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public enum HVACSystemPerformance: Equatable, Sendable {
|
public enum HVACSystemPerformance: Equatable, Sendable {
|
||||||
case index
|
case index
|
||||||
case submit(Routes.HVACSystemPerformance.Request)
|
case submit(Routes.HVACSystemPerformance.Request)
|
||||||
@@ -392,3 +451,5 @@ public extension SiteRoute {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swiftlint:enable type_body_length
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
private let numberFormatter: NumberFormatter = {
|
|
||||||
let formatter = NumberFormatter()
|
|
||||||
formatter.numberStyle = .decimal
|
|
||||||
formatter.maximumFractionDigits = 2
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
public extension String.StringInterpolation {
|
public extension String.StringInterpolation {
|
||||||
mutating func appendInterpolation(double: Double, fractionDigits: Int = 2) {
|
mutating func appendInterpolation(double: Double, fractionDigits: Int = 2) {
|
||||||
let formatter = NumberFormatter()
|
let formatter = NumberFormatter()
|
||||||
formatter.numberStyle = .decimal
|
formatter.numberStyle = .decimal
|
||||||
formatter.maximumFractionDigits = fractionDigits
|
formatter.maximumFractionDigits = fractionDigits
|
||||||
appendInterpolation(numberFormatter.string(from: NSNumber(value: double))!)
|
appendInterpolation(formatter.string(from: NSNumber(value: double))!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,17 +110,17 @@ public struct Toggle: HTML {
|
|||||||
switch isOn {
|
switch isOn {
|
||||||
case true:
|
case true:
|
||||||
SecondaryButton(label: onLabel)
|
SecondaryButton(label: onLabel)
|
||||||
.attributes(.class("rounded-s-lg"), .disabled)
|
.attributes(.class("rounded-s-lg"), .disabled, .type(.button))
|
||||||
|
|
||||||
PrimaryButton(label: offLabel)
|
PrimaryButton(label: offLabel)
|
||||||
.attributes(contentsOf: offAttributes + [.class("rounded-e-lg")])
|
.attributes(contentsOf: offAttributes + [.class("rounded-e-lg"), .type(.button)])
|
||||||
|
|
||||||
case false:
|
case false:
|
||||||
PrimaryButton(label: onLabel)
|
PrimaryButton(label: onLabel)
|
||||||
.attributes(contentsOf: onAttributes + [.class("rounded-s-lg")])
|
.attributes(contentsOf: onAttributes + [.class("rounded-s-lg"), .type(.button)])
|
||||||
|
|
||||||
SecondaryButton(label: offLabel)
|
SecondaryButton(label: offLabel)
|
||||||
.attributes(.class("rounded-e-lg"), .disabled)
|
.attributes(.class("rounded-e-lg"), .disabled, .type(.button))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ public enum SVGType: Sendable, CaseIterable {
|
|||||||
case leftRightArrow
|
case leftRightArrow
|
||||||
case menu
|
case menu
|
||||||
case ruler
|
case ruler
|
||||||
|
case scale
|
||||||
case thermometer
|
case thermometer
|
||||||
case thermometerSun
|
case thermometerSun
|
||||||
case wind
|
case wind
|
||||||
@@ -69,6 +70,7 @@ public enum SVGType: Sendable, CaseIterable {
|
|||||||
case .leftRightArrow: return leftRightArrowSvg(size: size)
|
case .leftRightArrow: return leftRightArrowSvg(size: size)
|
||||||
case .menu: return menuSvg(size: size)
|
case .menu: return menuSvg(size: size)
|
||||||
case .ruler: return rulerSvg(size: size)
|
case .ruler: return rulerSvg(size: size)
|
||||||
|
case .scale: return scaleSvg(size: size)
|
||||||
case .thermometer: return thermometerSvg(size: size)
|
case .thermometer: return thermometerSvg(size: size)
|
||||||
case .thermometerSun: return thermometerSunSvg(size: size)
|
case .thermometerSun: return thermometerSunSvg(size: size)
|
||||||
case .wind: return windSvg(size: size)
|
case .wind: return windSvg(size: size)
|
||||||
@@ -82,6 +84,17 @@ public enum SVGType: Sendable, CaseIterable {
|
|||||||
|
|
||||||
// swiftlint:disable line_length
|
// swiftlint:disable line_length
|
||||||
|
|
||||||
|
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">
|
||||||
|
<path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/>
|
||||||
|
<path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/>
|
||||||
|
<path d="M7 21h10"/><path d="M12 3v18"/>
|
||||||
|
<path d="M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/>
|
||||||
|
</svg>
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
private func checkCircleSvg(size: SVGSize) -> HTMLRaw {
|
private func checkCircleSvg(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-check-big">
|
<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-check-big">
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public struct WarningBox<Header: HTML>: HTML, Sendable {
|
|||||||
div(.id("warnings")) {
|
div(.id("warnings")) {
|
||||||
if warnings.count > 0 {
|
if warnings.count > 0 {
|
||||||
header(warnings)
|
header(warnings)
|
||||||
ul(.class("list-disc mx-10")) {
|
ul(.class("list-disc mx-10 mt-4")) {
|
||||||
for warning in warnings {
|
for warning in warnings {
|
||||||
li { warning }
|
li { warning }
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ public extension WarningBox where Header == HTMLElement<HTMLTag.span, HTMLText>
|
|||||||
self.init(
|
self.init(
|
||||||
warnings: warnings,
|
warnings: warnings,
|
||||||
header: { warnings in
|
header: { warnings in
|
||||||
span(.class("font-semibold mb-4 border-b")) { "Warning\(warnings.count > 1 ? "s:" : ":")" }
|
span(.class("font-semibold mb-4 border-b border-amber-500")) { "Warning\(warnings.count > 1 ? "s:" : ":")" }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ struct HomePage: HTML, Sendable {
|
|||||||
svg: .funnel,
|
svg: .funnel,
|
||||||
route: .filterPressureDrop(.index)
|
route: .filterPressureDrop(.index)
|
||||||
)
|
)
|
||||||
|
group(
|
||||||
|
label: "Heating Balance Point",
|
||||||
|
description: HeatingBalancePoint.description,
|
||||||
|
svg: .scale,
|
||||||
|
route: .heatingBalancePoint(.index)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,21 @@ extension ViewController: DependencyKey {
|
|||||||
return FilterPressureDropResponse(response: response)
|
return FilterPressureDropResponse(response: response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case let .heatingBalancePoint(route):
|
||||||
|
switch route {
|
||||||
|
case let .index(mode: mode, heatLossMode: heatLossMode):
|
||||||
|
return request.respond(HeatingBalancePointForm(
|
||||||
|
mode: mode,
|
||||||
|
heatLossMode: heatLossMode,
|
||||||
|
response: nil
|
||||||
|
))
|
||||||
|
case let .heatLossFields(mode: mode):
|
||||||
|
return HeatingBalancePointForm.HeatLossFields(mode: mode)
|
||||||
|
case let .submit(request):
|
||||||
|
let response = try await request.respond(logger: logger)
|
||||||
|
return HeatingBalancePointResponse(response: response)
|
||||||
|
}
|
||||||
|
|
||||||
case let .hvacSystemPerformance(route):
|
case let .hvacSystemPerformance(route):
|
||||||
switch route {
|
switch route {
|
||||||
case .index:
|
case .index:
|
||||||
|
|||||||
251
Sources/ViewController/Views/HeatingBalancePoint.swift
Normal file
251
Sources/ViewController/Views/HeatingBalancePoint.swift
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import Elementary
|
||||||
|
import ElementaryHTMX
|
||||||
|
import Routes
|
||||||
|
import Styleguide
|
||||||
|
|
||||||
|
struct HeatingBalancePointForm: HTML, Sendable {
|
||||||
|
let mode: HeatingBalancePoint.Mode
|
||||||
|
let heatLossMode: HeatingBalancePoint.HeatLoss.Mode
|
||||||
|
let response: HeatingBalancePoint.Response?
|
||||||
|
|
||||||
|
init(
|
||||||
|
mode: HeatingBalancePoint.Mode?,
|
||||||
|
heatLossMode: HeatingBalancePoint.HeatLoss.Mode? = nil,
|
||||||
|
response: HeatingBalancePoint.Response? = nil
|
||||||
|
) {
|
||||||
|
self.mode = mode ?? .thermal
|
||||||
|
self.heatLossMode = heatLossMode ?? .estimated
|
||||||
|
self.response = response
|
||||||
|
}
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
div(.class("flex flex-wrap justify-between")) {
|
||||||
|
FormHeader(label: "Balance Point - \(mode.label)", svg: .scale)
|
||||||
|
|
||||||
|
Toggle(
|
||||||
|
isOn: mode == .thermal,
|
||||||
|
onLabel: HeatingBalancePoint.Mode.thermal.label,
|
||||||
|
onAttributes: .hxDefaults(get: .heatingBalancePoint(.index(mode: .thermal, heatLossMode: heatLossMode))),
|
||||||
|
offLabel: HeatingBalancePoint.Mode.economic.label,
|
||||||
|
offAttributes: .hxDefaults(get: .heatingBalancePoint(.index(mode: .economic, heatLossMode: heatLossMode)))
|
||||||
|
)
|
||||||
|
.attributes(.class("mb-6"))
|
||||||
|
}
|
||||||
|
|
||||||
|
form(
|
||||||
|
.hx.post(route: .heatingBalancePoint(.index)),
|
||||||
|
.hx.target("#result")
|
||||||
|
) {
|
||||||
|
div(.class("space-y-6")) {
|
||||||
|
switch mode {
|
||||||
|
case .thermal:
|
||||||
|
ThermalFields(heatLossMode: heatLossMode)
|
||||||
|
case .economic:
|
||||||
|
div {
|
||||||
|
// FIX:
|
||||||
|
WarningBox("This is still under development and may not be fully functional.")
|
||||||
|
.attributes(.class("mb-6"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
SubmitButton(label: "Calculate Balance Point")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div(.id("result")) {
|
||||||
|
if let response {
|
||||||
|
HeatingBalancePointResponse(response: response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ThermalFields: HTML, Sendable {
|
||||||
|
let heatLossMode: HeatingBalancePoint.HeatLoss.Mode
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
LabeledContent(label: "System Size (Tons)") {
|
||||||
|
Input(id: "systemSize", placeholder: "System size")
|
||||||
|
.attributes(.type(.number), .min("1"), .step("0.5"), .autofocus, .required)
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
div(.class("mb-4")) {
|
||||||
|
h4(.class("text-lg font-bold")) { "Capacities" }
|
||||||
|
p(.class("text-xs text-blue-500")) {
|
||||||
|
"Entering known capacities gives better results, otherwise capacities will be estimated."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(.class("grid grid-cols-1 lg:grid-cols-2 gap-4")) {
|
||||||
|
LabeledContent(label: "Capacity @ 47° (BTU/h)") {
|
||||||
|
Input(id: "capacityAt47", placeholder: "Capacity @ 47° (optional)")
|
||||||
|
.attributes(.type(.number), .min("1"), .step("0.5"))
|
||||||
|
}
|
||||||
|
LabeledContent(label: "Capacity @ 17° (BTU/h)") {
|
||||||
|
Input(id: "capacityAt17", placeholder: "Capacity @ 17° (optional)")
|
||||||
|
.attributes(.type(.number), .min("1"), .step("0.5"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HeatLossFields(mode: heatLossMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HeatLossFields: HTML, Sendable {
|
||||||
|
let mode: HeatingBalancePoint.HeatLoss.Mode
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
div(.id("heatLossFields")) {
|
||||||
|
div(.class("flex flex-wrap justify-between")) {
|
||||||
|
h4(.class("text-lg font-bold")) { "Heat Loss - \(mode.label)" }
|
||||||
|
Toggle(
|
||||||
|
isOn: mode == .estimated,
|
||||||
|
onLabel: HeatingBalancePoint.HeatLoss.Mode.estimated.label,
|
||||||
|
onAttributes: [
|
||||||
|
.hx.get(route: .heatingBalancePoint(.heatLossFields(mode: .estimated))),
|
||||||
|
.hx.target("#heatLossFields")
|
||||||
|
],
|
||||||
|
offLabel: HeatingBalancePoint.HeatLoss.Mode.known.label,
|
||||||
|
offAttributes: [
|
||||||
|
.hx.get(route: .heatingBalancePoint(.heatLossFields(mode: .known))),
|
||||||
|
.hx.target("#heatLossFields")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.attributes(.class("mb-6"))
|
||||||
|
}
|
||||||
|
switch mode {
|
||||||
|
case .known:
|
||||||
|
knownFields
|
||||||
|
case .estimated:
|
||||||
|
simplifiedFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var simplifiedFields: some HTML {
|
||||||
|
div(.class("grid grid-cols-1 lg:grid-cols-3 gap-4")) {
|
||||||
|
LabeledContent(label: "Building Size (ft²)") {
|
||||||
|
Input(id: "simplifiedHeatLoss", placeholder: "Square feet")
|
||||||
|
.attributes(.type(.number), .min("1"), .step("0.5"), .required)
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
InputLabel(for: "climateZone") { "Climate Zone" }
|
||||||
|
Select(for: ClimateZone.self, id: "climateZone") { $0.rawValue }
|
||||||
|
}
|
||||||
|
LabeledContent(label: "Outdoor Design Temperature (°F)") {
|
||||||
|
Input(id: "heatingDesignTemperature", placeholder: "Design temperature (optional)")
|
||||||
|
.attributes(.type(.number), .step("0.5"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var knownFields: some HTML {
|
||||||
|
div(.class("grid grid-cols-1 lg:grid-cols-2 gap-4")) {
|
||||||
|
LabeledContent(label: "Heat Loss (BTU/h)") {
|
||||||
|
Input(id: "knownHeatLoss", placeholder: "Heat loss")
|
||||||
|
.attributes(.type(.number), .min("1"), .step("0.5"), .required)
|
||||||
|
}
|
||||||
|
LabeledContent(label: "Outdoor Design Temperature (°F)") {
|
||||||
|
Input(id: "heatingDesignTemperature", placeholder: "Design temperature")
|
||||||
|
.attributes(.type(.number), .step("0.5"), .required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HeatingBalancePointResponse: HTML, Sendable {
|
||||||
|
|
||||||
|
let response: HeatingBalancePoint.Response
|
||||||
|
|
||||||
|
var content: some HTML {
|
||||||
|
ResultContainer(reset: .heatingBalancePoint(.index(mode: response.mode, heatLossMode: nil))) {
|
||||||
|
div(
|
||||||
|
.class("""
|
||||||
|
w-full rounded-xl shadow-xl bg-blue-100 text-blue-600 border border-blue-600 py-4
|
||||||
|
""")
|
||||||
|
) {
|
||||||
|
div(.class("flex")) {
|
||||||
|
SVG(.scale, color: "text-blue-600")
|
||||||
|
.attributes(.class("px-4"))
|
||||||
|
p(.class("font-medium")) {
|
||||||
|
"\(response.mode.label) Balance Point"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch response {
|
||||||
|
case let .thermal(result):
|
||||||
|
thermalResult(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WarningBox(warnings: response.warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func thermalResult(_ result: HeatingBalancePoint.Response.Thermal) -> some HTML {
|
||||||
|
div {
|
||||||
|
VerticalGroup(
|
||||||
|
label: "Balance Point",
|
||||||
|
value: "\(double: result.balancePointTemperature, fractionDigits: 1)",
|
||||||
|
valueLabel: "°F"
|
||||||
|
)
|
||||||
|
.attributes(.class("mb-8"))
|
||||||
|
|
||||||
|
div(.class("grid grid-cols-2 space-y-6")) {
|
||||||
|
div {
|
||||||
|
VerticalGroup(
|
||||||
|
label: "Heat Loss - \(result.heatLossMode.label)",
|
||||||
|
value: "\(double: result.heatLoss, fractionDigits: 0)",
|
||||||
|
valueLabel: "BTU/h"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
VerticalGroup(
|
||||||
|
label: "Heating Design Temperature",
|
||||||
|
value: "\(double: result.heatingDesignTemperature, fractionDigits: 0)",
|
||||||
|
valueLabel: "°F"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
VerticalGroup(
|
||||||
|
label: "Capacity @ 47°",
|
||||||
|
value: "\(double: result.capacityAt47, fractionDigits: 0)",
|
||||||
|
valueLabel: "BTU/h"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
VerticalGroup(
|
||||||
|
label: "Capacity @ 17°",
|
||||||
|
value: "\(double: result.capacityAt17, fractionDigits: 0)",
|
||||||
|
valueLabel: "BTU/h"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension HeatingBalancePoint.Mode {
|
||||||
|
var label: String { rawValue.capitalized }
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension HeatingBalancePoint.HeatLoss.Mode {
|
||||||
|
var label: String { rawValue.capitalized }
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension HeatingBalancePoint.Response {
|
||||||
|
var mode: HeatingBalancePoint.Mode {
|
||||||
|
switch self {
|
||||||
|
case .thermal: return .thermal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var warnings: [String] {
|
||||||
|
switch self {
|
||||||
|
case let .thermal(result): return result.warnings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user