commit 5c684d053737433964e623999a010d8f9cfe3c9e Author: Michael Housh Date: Wed Mar 12 16:59:10 2025 -0400 feat: Initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7cfbe01 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*.swift] +indent_style = space +indent_size = 2 +tab_width = 2 +trim_trailing_whitespace = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..08f338e --- /dev/null +++ b/.swiftformat @@ -0,0 +1,11 @@ +--self init-only +--indent 2 +--ifdef indent +--trimwhitespace always +--wraparguments before-first +--wrapparameters before-first +--wrapcollections preserve +--wrapconditions after-first +--typeblanklines preserve +--commas inline +--stripunusedargs closure-only diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..213129c --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,11 @@ +disabled_rules: + - closing_brace + - fuction_body_length + - opening_brace + - nesting + +included: + - Sources + - Tests + +ignore_multiline_statement_conditions: true diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..2d6d94e --- /dev/null +++ b/Package.resolved @@ -0,0 +1,78 @@ +{ + "originHash" : "c1e2b47f4dcb7c57922f698b2ddfff2e29dfa400eb006d972bfea53df688f5d9", + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths.git", + "state" : { + "revision" : "19b7263bacb9751f151ec0c93ec816fe1ef67c7b", + "version" : "1.6.1" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies.git", + "state" : { + "revision" : "ec2862d1364536fc22ec56a3094e7a034bbc7da8", + "version" : "1.8.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, + { + "identity" : "swift-validations", + "kind" : "remoteSourceControl", + "location" : "https://github.com/m-housh/swift-validations.git", + "state" : { + "revision" : "95ea5d267e37f6cdb9f91c5c8a01e718b9299db6", + "version" : "0.3.4" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", + "version" : "1.5.2" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..c88e347 --- /dev/null +++ b/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 6.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "swift-manual-s", + platforms: [ + .macOS(.v10_15) + ], + products: [ + .library(name: "ManualS", targets: ["ManualS"]) + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-dependencies.git", from: "1.0.0"), + .package(url: "https://github.com/m-housh/swift-validations.git", from: "0.3.4") + ], + targets: [ + .target( + name: "ManualS", + dependencies: [ + "Models", + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + .product(name: "Validations", package: "swift-validations") + ] + ), + .target( + name: "Models" + ), + .testTarget( + name: "ManualSTests", + dependencies: ["ManualS"] + ) + ] +) diff --git a/Sources/ManualS/Exports.swift b/Sources/ManualS/Exports.swift new file mode 100644 index 0000000..4de8be8 --- /dev/null +++ b/Sources/ManualS/Exports.swift @@ -0,0 +1,2 @@ +@_exported import Dependencies +@_exported import Models diff --git a/Sources/ManualS/Extensions/DesignInfo+validator.swift b/Sources/ManualS/Extensions/DesignInfo+validator.swift new file mode 100644 index 0000000..92ce038 --- /dev/null +++ b/Sources/ManualS/Extensions/DesignInfo+validator.swift @@ -0,0 +1,19 @@ +import Models +import Validations + +extension DesignInfo: AsyncValidatable { + public var body: some AsyncValidation { + AsyncValidator.validate(\.summer) + } +} + +extension DesignInfo.Summer: AsyncValidatable { + + public var body: some AsyncValidation { + AsyncValidator.accumulating { + AsyncValidator.greaterThan(\.outdoorTemperature, 0) + AsyncValidator.greaterThan(\.indoorTemperature, 0) + AsyncValidator.greaterThan(\.indoorHumidity, 0) + } + } +} diff --git a/Sources/ManualS/Extensions/HeatPumpCapacity+validator.swift b/Sources/ManualS/Extensions/HeatPumpCapacity+validator.swift new file mode 100644 index 0000000..0cdfeca --- /dev/null +++ b/Sources/ManualS/Extensions/HeatPumpCapacity+validator.swift @@ -0,0 +1,13 @@ +import Models +import Validations + +extension Capacity.HeatPumpHeating: AsyncValidatable { + + public var body: some AsyncValidation { + AsyncValidator.accumulating { + AsyncValidator.greaterThan(\.at47, 0) + AsyncValidator.greaterThan(\.at17, 0) + AsyncValidator.greaterThanOrEquals(\.at47, \.at17) + } + } +} diff --git a/Sources/ManualS/Extensions/HouseLoad+validator.swift b/Sources/ManualS/Extensions/HouseLoad+validator.swift new file mode 100644 index 0000000..4bd21b4 --- /dev/null +++ b/Sources/ManualS/Extensions/HouseLoad+validator.swift @@ -0,0 +1,14 @@ +import Models +import Validations + +extension HouseLoad: AsyncValidatable { + + public var body: some AsyncValidation { + AsyncValidator.accumulating { + AsyncValidator.greaterThan(\.coolingTotal, 0) + AsyncValidator.greaterThan(\.coolingSensible, 0) + AsyncValidator.greaterThan(\.heating, 0) + AsyncValidator.greaterThanOrEquals(\.coolingTotal, \.coolingSensible) + } + } +} diff --git a/Sources/ManualS/Extensions/ManufacturersCoolingCapacity+validator.swift b/Sources/ManualS/Extensions/ManufacturersCoolingCapacity+validator.swift new file mode 100644 index 0000000..38c1d46 --- /dev/null +++ b/Sources/ManualS/Extensions/ManufacturersCoolingCapacity+validator.swift @@ -0,0 +1,34 @@ +import Models +import Validations + +extension Capacity.ManufacturersCooling: AsyncValidatable { + public var body: some AsyncValidation { + AsyncValidator.accumulating { + AsyncValidator.greaterThan(\.airflow, 0) + AsyncValidator.validate(\.capacity) + AsyncValidator.validate(\.otherCapacity, with: OptionalContainerValidator()) + } + } +} + +extension Capacity.ManufacturersCooling.Container: AsyncValidatable { + public var body: some AsyncValidation { + AsyncValidator.accumulating { + AsyncValidator.greaterThan(\.dryBulbTemperature, 0) + AsyncValidator.greaterThan(\.wetBulbTemperature, 0) + AsyncValidator.greaterThan(\.totalCapacity, 0) + AsyncValidator.greaterThan(\.sensibleCapacity, 0) + AsyncValidator.greaterThanOrEquals(\.totalCapacity, \.sensibleCapacity) + } + } +} + +private struct OptionalContainerValidator: AsyncValidation { + typealias Value = Capacity.ManufacturersCooling.Container? + + func validate(_ value: Capacity.ManufacturersCooling.Container?) async throws { + guard let value else { return } + try await value.validate() + } + +} diff --git a/Sources/ManualS/Internal/BalancePoint.swift b/Sources/ManualS/Internal/BalancePoint.swift new file mode 100644 index 0000000..0984707 --- /dev/null +++ b/Sources/ManualS/Internal/BalancePoint.swift @@ -0,0 +1,39 @@ +import CoreFoundation +import Models +import Validations + +extension BalancePoint.Request { + + func respond() async throws -> BalancePoint.Response { + try await validate() + let balancePoint = await thermalBalancePoint( + heatLoss: Double(heatLoss), + at47: Double(heatPumpCapacity.at47), + at17: Double(heatPumpCapacity.at17), + designTemperature: Double(winterDesignTemperature) + ) + return .init(balancePointTemperature: balancePoint) + } +} + +extension BalancePoint.Request: AsyncValidatable { + + @inlinable + public var body: some AsyncValidation { + AsyncValidator.accumulating { + AsyncValidator.greaterThan(\.heatLoss, 0) + AsyncValidator.validate(\.heatPumpCapacity) + } + } +} + +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))) +} diff --git a/Sources/ManualS/Internal/Derating.swift b/Sources/ManualS/Internal/Derating.swift new file mode 100644 index 0000000..1e3d78e --- /dev/null +++ b/Sources/ManualS/Internal/Derating.swift @@ -0,0 +1,101 @@ +import Models + +extension Derating.Request { + func respond() async throws -> Derating.Response { + switch systemType { + case .airToAir: + return .init( + coolingTotal: totalWetDerating(elevation: elevation), + coolingSensible: sensibleWetDerating(elevation: elevation), + heating: totalDryDerating(elevation: elevation) + ) + + case let .heatingOnly(type: type): + switch type { + case .boiler, .furnace: + return .init(heating: furnaceDerating(elevation: elevation)) + case .electric: + return .init(heating: 1) + } + } + } +} + +// swiftlint:disable cyclomatic_complexity +private func furnaceDerating(elevation: Int) -> Double { + guard elevation > 0 else { return 1 } + + if (0 ..< 1000).contains(elevation) { return 1 } + if (1000 ..< 2000).contains(elevation) { return 0.96 } + if (2000 ..< 3000).contains(elevation) { return 0.92 } + if (3000 ..< 4000).contains(elevation) { return 0.88 } + if (4000 ..< 5000).contains(elevation) { return 0.84 } + if (5000 ..< 6000).contains(elevation) { return 0.8 } + if (6000 ..< 7000).contains(elevation) { return 0.76 } + if (7000 ..< 8000).contains(elevation) { return 0.72 } + if (8000 ..< 9000).contains(elevation) { return 0.68 } + if (9000 ..< 10000).contains(elevation) { return 0.64 } + if (10000 ..< 11000).contains(elevation) { return 0.6 } + if (11000 ..< 12000).contains(elevation) { return 0.56 } + // greater than 12,000 feet in elevation. + return 0.52 +} + +private func totalWetDerating(elevation: Int) -> Double { + guard elevation > 0 else { return 1 } + + if (0 ..< 1000).contains(elevation) { return 1 } + if (1000 ..< 2000).contains(elevation) { return 0.99 } + if (2000 ..< 3000).contains(elevation) { return 0.98 } + if (3000 ..< 4000).contains(elevation) { return 0.98 } + if (4000 ..< 5000).contains(elevation) { return 0.97 } + if (5000 ..< 6000).contains(elevation) { return 0.96 } + if (6000 ..< 7000).contains(elevation) { return 0.95 } + if (7000 ..< 8000).contains(elevation) { return 0.94 } + if (8000 ..< 9000).contains(elevation) { return 0.94 } + if (9000 ..< 10000).contains(elevation) { return 0.93 } + if (10000 ..< 11000).contains(elevation) { return 0.92 } + if (11000 ..< 12000).contains(elevation) { return 0.91 } + // greater than 12,000 feet in elevation. + return 0.9 +} + +private func sensibleWetDerating(elevation: Int) -> Double { + guard elevation > 0 else { return 1 } + + if (0 ..< 1000).contains(elevation) { return 1 } + if (1000 ..< 2000).contains(elevation) { return 0.97 } + if (2000 ..< 3000).contains(elevation) { return 0.94 } + if (3000 ..< 4000).contains(elevation) { return 0.91 } + if (4000 ..< 5000).contains(elevation) { return 0.88 } + if (5000 ..< 6000).contains(elevation) { return 0.85 } + if (6000 ..< 7000).contains(elevation) { return 0.82 } + if (7000 ..< 8000).contains(elevation) { return 0.8 } + if (8000 ..< 9000).contains(elevation) { return 0.77 } + if (9000 ..< 10000).contains(elevation) { return 0.74 } + if (10000 ..< 11000).contains(elevation) { return 0.71 } + if (11000 ..< 12000).contains(elevation) { return 0.68 } + // greater than 12,000 feet in elevation. + return 0.65 +} + +private func totalDryDerating(elevation: Int) -> Double { + guard elevation > 0 else { return 1 } + + if (0 ..< 1000).contains(elevation) { return 1 } + if (1000 ..< 2000).contains(elevation) { return 0.98 } + if (2000 ..< 3000).contains(elevation) { return 0.97 } + if (3000 ..< 4000).contains(elevation) { return 0.95 } + if (4000 ..< 5000).contains(elevation) { return 0.94 } + if (5000 ..< 6000).contains(elevation) { return 0.92 } + if (6000 ..< 7000).contains(elevation) { return 0.9 } + if (7000 ..< 8000).contains(elevation) { return 0.89 } + if (8000 ..< 9000).contains(elevation) { return 0.87 } + if (9000 ..< 10000).contains(elevation) { return 0.86 } + if (10000 ..< 11000).contains(elevation) { return 0.84 } + if (11000 ..< 12000).contains(elevation) { return 0.82 } + // greater than 12,000 feet in elevation. + return 0.81 +} + +// swiftlint:enable cyclomatic_complexity diff --git a/Sources/ManualS/Internal/Interpolate.swift b/Sources/ManualS/Internal/Interpolate.swift new file mode 100644 index 0000000..43358fe --- /dev/null +++ b/Sources/ManualS/Internal/Interpolate.swift @@ -0,0 +1,104 @@ +import Models +import Validations + +extension Interpolate.Request { + func respond() async throws -> Interpolate.Response { + try await validate() + fatalError() + } +} + +// Basic validations of the request. +extension Interpolate.Request: AsyncValidatable { + public var body: some AsyncValidation { + AsyncValidator.accumulating { + AsyncValidator.validate(\.designInfo) + AsyncValidator.validate(\.houseLoad) + AsyncValidator.validate(\.manufacturersCapacity) + } + } +} + +private extension Interpolate.Request { + + func parseInterpolationType() throws -> Interpolate.InterpolationType { + guard let otherCapacity = manufacturersCapacity.otherCapacity else { + let capacity = manufacturersCapacity.capacity + guard capacity.wetBulbTemperature == 63 else { + throw ValidationError( + message: "Expected manufacturers wet bulb temperature to be 63, but found: \(capacity.wetBulbTemperature)" + ) + } + return .noInterpolation + } + + // check if the + fatalError() + } + + private func checkOneWayIndoorInterpolation( + _ capacity: Capacity.ManufacturersCooling.Container, + _ other: Capacity.ManufacturersCooling.Container + ) -> CapacityContainer? { + // ensure outdoor temperatures match, otherwise we are not a one way indoor interpolation. + guard capacity.outdoorTemperature == other.outdoorTemperature else { + return nil + } + + // ensure indoor temperatures are not the same. + guard capacity.dryBulbTemperature != other.dryBulbTemperature else { + return nil + } + + if capacity.dryBulbTemperature < other.dryBulbTemperature { + return .init(above: other, below: capacity) + } + + return .init(above: capacity, below: other) + } + + private func checkOneWayOutdoorInterpolation( + _ capacity: Capacity.ManufacturersCooling.Container, + _ other: Capacity.ManufacturersCooling.Container + ) -> CapacityContainer? { + // ensure outdoor temperatures match, otherwise we are not a one way indoor interpolation. + guard capacity.dryBulbTemperature == other.dryBulbTemperature else { + return nil + } + + // ensure outdoor temperatures are not the same. + guard capacity.outdoorTemperature != other.outdoorTemperature else { + return nil + } + + if capacity.outdoorTemperature < other.outdoorTemperature { + return .init(above: other, below: capacity) + } + + return .init(above: capacity, below: other) + } + + private func checkTwoWayInterpolation( + _ capacity: Capacity.ManufacturersCooling.Container, + _ other: Capacity.ManufacturersCooling.Container + ) -> CapacityContainer? { + // ensure outdoor temperatures do not match. + guard capacity.outdoorTemperature != other.outdoorTemperature else { return nil } + guard capacity.dryBulbTemperature != other.dryBulbTemperature else { return nil } + + return nil + } +} + +private struct CapacityContainer { + let above: Capacity.ManufacturersCooling.Container + let below: Capacity.ManufacturersCooling.Container +} + +public struct ValidationError: Equatable, Error { + public let message: String + + public init(message: String) { + self.message = message + } +} diff --git a/Sources/ManualS/Internal/RequiredKW.swift b/Sources/ManualS/Internal/RequiredKW.swift new file mode 100644 index 0000000..bdfd875 --- /dev/null +++ b/Sources/ManualS/Internal/RequiredKW.swift @@ -0,0 +1,24 @@ +import Models +import Validations + +extension RequiredKW.Request { + func respond() async throws -> RequiredKW.Response { + try await validate() + let capacityAtDesign = self.capacityAtDesign ?? 0 + let requiredKW = (Double(heatLoss) - Double(capacityAtDesign)) / 3413 + return .init(requiredKW: requiredKW) + } +} + +extension RequiredKW.Request: AsyncValidatable { + + @inlinable + public var body: some AsyncValidation { + AsyncValidator.accumulating { + AsyncValidator.greaterThan(\.heatLoss, 0) + AsyncValidator.validate(\.capacityAtDesign) { + AsyncValidator.greaterThan(0).optional() + } + } + } +} diff --git a/Sources/ManualS/Internal/SizingLimits.swift b/Sources/ManualS/Internal/SizingLimits.swift new file mode 100644 index 0000000..c4cb328 --- /dev/null +++ b/Sources/ManualS/Internal/SizingLimits.swift @@ -0,0 +1,79 @@ +import CoreFoundation +import Models + +extension SizingLimits.Request { + func respond() async throws -> SizingLimits.Response { + return try .init( + oversizing: .oversizingLimit(systemType: systemType, houseLoad: houseLoad), + undersizing: .undersizingLimits + ) + } +} + +private extension SizingLimits.Limits { + static let undersizingLimits = Self( + heating: 90, + coolingTotal: 90, + coolingSensible: 90, + coolingLatent: 90 + ) + + static func oversizingLimit( + systemType: SystemType, + houseLoad: HouseLoad? + ) throws -> Self { + switch systemType { + case let .heatingOnly(type: type): + return .init(heating: type.oversizingLimit(), coolingTotal: 115) + case let .airToAir(type: _, compressor: compressor, climate: climate): + return try .init( + heating: 140, + coolingTotal: coolingTotalOversizingLimit( + houseLoad: houseLoad, + compressorType: compressor, + climateType: climate + ), + coolingSensible: nil, + coolingLatent: 150 + ) + } + } +} + +private extension SystemType.HeatingOnlyType { + + func oversizingLimit() -> Int { + switch self { + case .boiler, .furnace: return 140 + case .electric: return 175 + } + } +} + +private func coolingTotalOversizingLimit( + houseLoad: HouseLoad?, + compressorType: SystemType.CompressorType, + climateType: SystemType.ClimateType +) throws -> Int { + switch (compressorType, climateType) { + case (.singleSpeed, .mildWinterOrLatentLoad): + return 115 + case (.multiSpeed, .mildWinterOrLatentLoad): + return 120 + case (.variableSpeed, .mildWinterOrLatentLoad): + return 130 + default: + + guard let houseLoad else { + throw HouseLoadError() + } + let decimal = Double(houseLoad.coolingTotal + 15000) / Double(houseLoad.coolingTotal) + return Int(round(decimal * 100)) + } +} + +public struct HouseLoadError: Error, Equatable { + public let message = "House load not supplied." + + public init() {} +} diff --git a/Sources/ManualS/ManualS.swift b/Sources/ManualS/ManualS.swift new file mode 100644 index 0000000..ef7832c --- /dev/null +++ b/Sources/ManualS/ManualS.swift @@ -0,0 +1,33 @@ +import Dependencies +import DependenciesMacros +import Models + +public extension DependencyValues { + var manualS: ManualS { + get { self[ManualS.self] } + set { self[ManualS.self] = newValue } + } +} + +@DependencyClient +public struct ManualS: Sendable { + public var balancePoint: @Sendable (BalancePoint.Request) async throws -> BalancePoint.Response + public var derating: @Sendable (Derating.Request) async throws -> Derating.Response + public var interpolate: @Sendable (Interpolate.Request) async throws -> Interpolate.Response + public var requiredKW: @Sendable (RequiredKW.Request) async throws -> RequiredKW.Response + public var sizingLimits: @Sendable (SizingLimits.Request) async throws -> SizingLimits.Response +} + +extension ManualS: DependencyKey { + public static let liveValue = Self( + balancePoint: { try await $0.respond() }, + derating: { try await $0.respond() }, + interpolate: { try await $0.respond() }, + requiredKW: { try await $0.respond() }, + sizingLimits: { try await $0.respond() } + ) +} + +extension ManualS: TestDependencyKey { + public static let testValue: ManualS = Self() +} diff --git a/Sources/Models/BalancePoint.swift b/Sources/Models/BalancePoint.swift new file mode 100644 index 0000000..2990a77 --- /dev/null +++ b/Sources/Models/BalancePoint.swift @@ -0,0 +1,24 @@ +public enum BalancePoint { + + public struct Request: Codable, Equatable, Sendable { + + public let winterDesignTemperature: Int + public let heatLoss: Int + public let heatPumpCapacity: Capacity.HeatPumpHeating + + public init(winterDesignTemperature: Int, heatLoss: Int, heatPumpCapacity: Capacity.HeatPumpHeating) { + self.winterDesignTemperature = winterDesignTemperature + self.heatLoss = heatLoss + self.heatPumpCapacity = heatPumpCapacity + } + } + + public struct Response: Codable, Equatable, Sendable { + + public let balancePointTemperature: Double + + public init(balancePointTemperature: Double) { + self.balancePointTemperature = balancePointTemperature + } + } +} diff --git a/Sources/Models/Core/AdjustmentMultiplier.swift b/Sources/Models/Core/AdjustmentMultiplier.swift new file mode 100644 index 0000000..78407a2 --- /dev/null +++ b/Sources/Models/Core/AdjustmentMultiplier.swift @@ -0,0 +1,12 @@ +public struct AdjustmentMultiplier: Codable, Equatable, Sendable { + + public let coolingTotal: Double? + public let coolingSensible: Double? + public let heating: Double + + public init(coolingTotal: Double? = nil, coolingSensible: Double? = nil, heating: Double) { + self.coolingTotal = coolingTotal + self.coolingSensible = coolingSensible + self.heating = heating + } +} diff --git a/Sources/Models/Core/Capacity.swift b/Sources/Models/Core/Capacity.swift new file mode 100644 index 0000000..412371e --- /dev/null +++ b/Sources/Models/Core/Capacity.swift @@ -0,0 +1,104 @@ +// A container / namespace for different capacity containers. +public enum Capacity { + + public struct Cooling: Codable, Equatable, Sendable { + + public let total: Int + public let sensible: Int + public var latent: Int + + public init( + total: Int, + sensible: Int, + latent: Int? = nil + ) { + self.total = total + self.sensible = sensible + self.latent = latent ?? total - sensible + } + } + + public struct Heating: Codable, Equatable, Sendable { + public let total: Int + + public init(total: Int) { + self.total = total + } + } + + public struct HeatPumpHeating: Codable, Equatable, Sendable { + + public let at47: Int + public let at17: Int + + public init(at47: Int, at17: Int) { + self.at47 = at47 + self.at17 = at17 + } + } + + public struct ManufacturersCooling: Codable, Equatable, Sendable { + + public let airflow: Int + public let capacity: Container + public let otherCapacity: Container? + + public init( + airflow: Int, + capacity: Capacity.ManufacturersCooling.Container, + otherCapacity: Capacity.ManufacturersCooling.Container? = nil + ) { + self.airflow = airflow + self.capacity = capacity + self.otherCapacity = otherCapacity + } + + public struct Container: Codable, Equatable, Sendable { + + public let dryBulbTemperature: Int + public let wetBulbTemperature: Int + public let outdoorTemperature: Int + public let totalCapacity: Int + public let sensibleCapacity: Int + public let adjustmentMultipliers: AdjustmentMultiplier? + + public var latentCapacity: Int { totalCapacity - sensibleCapacity } + + public init( + dryBulbTemperature: Int, + wetBulbTemperature: Int, + outdoorTemperature: Int, + totalCapacity: Int, + sensibleCapacity: Int, + adjustmentMultipliers: AdjustmentMultiplier? = nil + ) { + self.dryBulbTemperature = dryBulbTemperature + self.wetBulbTemperature = wetBulbTemperature + self.outdoorTemperature = outdoorTemperature + self.totalCapacity = totalCapacity + self.sensibleCapacity = sensibleCapacity + self.adjustmentMultipliers = adjustmentMultipliers + } + } + } + + public struct ManufacturersContainer: Codable, Equatable, Sendable { + + public let wetBulb: Int + public let totalCapacity: Int + public let sensibleCapacity: Int + public let adjustmentMultipliers: AdjustmentMultiplier? + + public init( + wetBulb: Int, + totalCapacity: Int, + sensibleCapacity: Int, + adjustmentMultipliers: AdjustmentMultiplier? = nil + ) { + self.wetBulb = wetBulb + self.totalCapacity = totalCapacity + self.sensibleCapacity = sensibleCapacity + self.adjustmentMultipliers = adjustmentMultipliers + } + } +} diff --git a/Sources/Models/Core/DesignInfo.swift b/Sources/Models/Core/DesignInfo.swift new file mode 100644 index 0000000..e2ab4ae --- /dev/null +++ b/Sources/Models/Core/DesignInfo.swift @@ -0,0 +1,45 @@ +import Foundation + +public struct DesignInfo: Codable, Equatable, Sendable { + + public let summer: Summer + public let winter: Winter + public let elevation: Int + + public init( + summer: DesignInfo.Summer = .init(), + winter: DesignInfo.Winter = .init(), + elevation: Int = 0 + ) { + self.summer = summer + self.winter = winter + self.elevation = elevation + } +} + +public extension DesignInfo { + struct Summer: Codable, Equatable, Sendable { + + public let outdoorTemperature: Int + public let indoorTemperature: Int + public let indoorHumidity: Int + + public init( + outdoorTemperature: Int = 90, + indoorTemperature: Int = 75, + indoorHumidity: Int = 50 + ) { + self.outdoorTemperature = outdoorTemperature + self.indoorTemperature = indoorTemperature + self.indoorHumidity = indoorHumidity + } + } + + struct Winter: Codable, Equatable, Sendable { + public let outdoorTemperature: Int + + public init(outdoorTemperature: Int = 5) { + self.outdoorTemperature = outdoorTemperature + } + } +} diff --git a/Sources/Models/Core/HouseLoad.swift b/Sources/Models/Core/HouseLoad.swift new file mode 100644 index 0000000..05093f7 --- /dev/null +++ b/Sources/Models/Core/HouseLoad.swift @@ -0,0 +1,18 @@ +public struct HouseLoad: Codable, Equatable, Sendable { + + public let coolingTotal: Int + public let coolingSensible: Int + public let heating: Int + + public var coolingLatent: Int { coolingTotal - coolingSensible } + + public init( + coolingTotal: Int, + coolingSensible: Int, + heating: Int + ) { + self.coolingTotal = coolingTotal + self.coolingSensible = coolingSensible + self.heating = heating + } +} diff --git a/Sources/Models/Core/SystemType.swift b/Sources/Models/Core/SystemType.swift new file mode 100644 index 0000000..bc0edfa --- /dev/null +++ b/Sources/Models/Core/SystemType.swift @@ -0,0 +1,27 @@ +public enum SystemType: Codable, Equatable, Sendable { + + case airToAir(type: EquipmentType, compressor: CompressorType, climate: ClimateType) + case heatingOnly(type: HeatingOnlyType) + + public enum ClimateType: String, CaseIterable, Codable, Equatable, Sendable { + case mildWinterOrLatentLoad + case coldWinterOrNoLatentLoad + } + + public enum CompressorType: String, CaseIterable, Codable, Equatable, Sendable { + case singleSpeed + case multiSpeed + case variableSpeed + } + + public enum EquipmentType: String, CaseIterable, Codable, Equatable, Sendable { + case airConditioner + case heatPump + } + + public enum HeatingOnlyType: String, CaseIterable, Codable, Equatable, Sendable { + case boiler + case electric + case furnace + } +} diff --git a/Sources/Models/Derating.swift b/Sources/Models/Derating.swift new file mode 100644 index 0000000..2cf11f9 --- /dev/null +++ b/Sources/Models/Derating.swift @@ -0,0 +1,15 @@ +public enum Derating { + + public struct Request: Codable, Equatable, Sendable { + + public let elevation: Int + public let systemType: SystemType + + public init(elevation: Int, systemType: SystemType) { + self.elevation = elevation + self.systemType = systemType + } + } + + public typealias Response = AdjustmentMultiplier +} diff --git a/Sources/Models/Interpolate.swift b/Sources/Models/Interpolate.swift new file mode 100644 index 0000000..5b2b020 --- /dev/null +++ b/Sources/Models/Interpolate.swift @@ -0,0 +1,148 @@ +public enum Interpolate { + + public struct Request: Codable, Equatable, Sendable { + + public let designInfo: DesignInfo + public let houseLoad: HouseLoad + public let systemType: SystemType + public let manufacturersCapacity: Capacity.ManufacturersCooling + + public init( + designInfo: DesignInfo, + houseLoad: HouseLoad, + systemType: SystemType, + manufacturersCapacity: Capacity.ManufacturersCooling + ) { + self.designInfo = designInfo + self.houseLoad = houseLoad + self.systemType = systemType + self.manufacturersCapacity = manufacturersCapacity + } + } + + public struct Response: Codable, Equatable, Sendable { + + public let failed: Bool + public let failures: [String]? + public let interpolationType: InterpolationType + public let interpolatedCapacity: Capacity.Cooling + public let excessLatent: Int + public let finalCapacityAtDesign: Capacity.Cooling + public let altitudeDerating: AdjustmentMultiplier? + public let capacityAsPercentOfLoad: Capacity.Cooling + public let sizingLimits: SizingLimits.Response + + public init( + failures: [String]? = nil, + interpolationType: InterpolationType, + interpolatedCapacity: Capacity.Cooling, + excessLatent: Int, + finalCapacityAtDesign: Capacity.Cooling, + altitudeDerating: AdjustmentMultiplier? = nil, + capacityAsPercentOfLoad: Capacity.Cooling, + sizingLimits: SizingLimits.Response + ) { + self.failed = failures != nil ? failures!.count > 0 : false + self.failures = failures + self.interpolationType = interpolationType + self.interpolatedCapacity = interpolatedCapacity + self.excessLatent = excessLatent + self.finalCapacityAtDesign = finalCapacityAtDesign + self.altitudeDerating = altitudeDerating + self.capacityAsPercentOfLoad = capacityAsPercentOfLoad + self.sizingLimits = sizingLimits + } + } + + public enum InterpolationType2: Codable, Equatable, Sendable { + case noInterpolation(Capacity.ManufacturersCooling) + case oneWayIndoor(OneWayIndoor) + case oneWayOutdoor(OneWayOutdoor) + } + + public struct OneWayIndoor: Codable, Equatable, Sendable { + + public let airflow: Int + public let outdoorTemperature: Int + public let capacities: Capacities + public let adjustmentMultipliers: AdjustmentMultiplier? + + public init( + airflow: Int, + outdoorTemperature: Int, + capacities: Interpolate.OneWayIndoor.Capacities, + adjustmentMultipliers: AdjustmentMultiplier? = nil + ) { + self.airflow = airflow + self.outdoorTemperature = outdoorTemperature + self.capacities = capacities + self.adjustmentMultipliers = adjustmentMultipliers + } + + public struct Capacities: Codable, Equatable, Sendable { + + public let aboveDewpoint: Capacity.ManufacturersContainer + public let belowDewpoint: Capacity.ManufacturersContainer + + public init(aboveDewpoint: Capacity.ManufacturersContainer, belowDewpoint: Capacity.ManufacturersContainer) { + self.aboveDewpoint = aboveDewpoint + self.belowDewpoint = belowDewpoint + } + } + } + + public struct OneWayOutdoor: Codable, Equatable, Sendable { + + public let airflow: Int + public let wetBulb: Int + public let capacities: Capacities + public let adjustmentMultipliers: AdjustmentMultiplier? + + public init( + airflow: Int, + wetBulb: Int, + capacities: Interpolate.OneWayOutdoor.Capacities, + adjustmentMultipliers: AdjustmentMultiplier? = nil + ) { + self.airflow = airflow + self.wetBulb = wetBulb + self.capacities = capacities + self.adjustmentMultipliers = adjustmentMultipliers + } + + public struct Capacities: Codable, Equatable, Sendable { + + public let aboveOutdoor: Capacity + public let belowOutdoor: Capacity + + public init( + aboveOutdoor: Interpolate.OneWayOutdoor.Capacities.Capacity, + belowOutdoor: Interpolate.OneWayOutdoor.Capacities.Capacity + ) { + self.aboveOutdoor = aboveOutdoor + self.belowOutdoor = belowOutdoor + } + + public struct Capacity: Codable, Equatable, Sendable { + + public let outdoorTemperature: Int + public let totalCapacity: Int + public let sensibleCapacity: Int + + public init(outdoorTemperature: Int, totalCapacity: Int, sensibleCapacity: Int) { + self.outdoorTemperature = outdoorTemperature + self.totalCapacity = totalCapacity + self.sensibleCapacity = sensibleCapacity + } + } + } + + } + + public enum InterpolationType: String, CaseIterable, Codable, Equatable, Sendable { + case noInterpolation + case oneWayIndoor + case oneWayOutdoor + case twoWay + } +} diff --git a/Sources/Models/RequiredKW.swift b/Sources/Models/RequiredKW.swift new file mode 100644 index 0000000..77651e3 --- /dev/null +++ b/Sources/Models/RequiredKW.swift @@ -0,0 +1,22 @@ +public enum RequiredKW { + + public struct Request: Codable, Equatable, Sendable { + + public let capacityAtDesign: Int? + public let heatLoss: Int + + public init(capacityAtDesign: Int? = nil, heatLoss: Int) { + self.capacityAtDesign = capacityAtDesign + self.heatLoss = heatLoss + } + } + + public struct Response: Codable, Equatable, Sendable { + + public let requiredKW: Double + + public init(requiredKW: Double) { + self.requiredKW = requiredKW + } + } +} diff --git a/Sources/Models/SizingLimits.swift b/Sources/Models/SizingLimits.swift new file mode 100644 index 0000000..b4a0f41 --- /dev/null +++ b/Sources/Models/SizingLimits.swift @@ -0,0 +1,44 @@ +public enum SizingLimits { + + public struct Request: Codable, Equatable, Sendable { + + public let systemType: SystemType + public let houseLoad: HouseLoad? + + public init(systemType: SystemType, houseLoad: HouseLoad? = nil) { + self.systemType = systemType + self.houseLoad = houseLoad + } + } + + public struct Response: Codable, Equatable, Sendable { + + public let oversizing: SizingLimits.Limits + public let undersizing: SizingLimits.Limits + + public init(oversizing: SizingLimits.Limits, undersizing: SizingLimits.Limits) { + self.oversizing = oversizing + self.undersizing = undersizing + } + } + + public struct Limits: Codable, Equatable, Sendable { + + public let heating: Int + public let coolingTotal: Int + public let coolingSensible: Int? + public let coolingLatent: Int? + + public init( + heating: Int, + coolingTotal: Int, + coolingSensible: Int? = nil, + coolingLatent: Int? = nil + ) { + self.heating = heating + self.coolingTotal = coolingTotal + self.coolingSensible = coolingSensible + self.coolingLatent = coolingLatent + } + } +} diff --git a/Tests/ManualSTests/ManualSTests.swift b/Tests/ManualSTests/ManualSTests.swift new file mode 100644 index 0000000..5b8ca9e --- /dev/null +++ b/Tests/ManualSTests/ManualSTests.swift @@ -0,0 +1,213 @@ +import CoreFoundation +import Dependencies +import ManualS +import Testing + +@Suite("ManualSTests") +struct ManualSTests { + + @Test + func balancePoint() async throws { + try await withDependencies { + $0.manualS = .liveValue + } operation: { + @Dependency(\.manualS) var manualS + let balancePoint = try await manualS.balancePoint(.init( + winterDesignTemperature: 5, + heatLoss: 49667, + heatPumpCapacity: .init(at47: 24600, at17: 15100) + )) + let rounded = round(balancePoint.balancePointTemperature * 10) / 10 + #expect(rounded == 38.5) + } + } + + @Test( + arguments: SystemType.makeAirToAirTestCases(climate: .mildWinterOrLatentLoad) + ) + func mildWinterSizingLimits(system: SystemType) async throws { + try await withDependencies { + $0.manualS = .liveValue + } operation: { + @Dependency(\.manualS) var manualS + + let limits = try await manualS.sizingLimits(.init(systemType: system)) + + #expect(limits.oversizing.coolingLatent == 150) + #expect(limits.oversizing.coolingTotal == system.compressorType!.mildWinterTotalLimit) + #expect(limits.undersizing.coolingTotal == 90) + #expect(limits.undersizing.coolingSensible == 90) + #expect(limits.undersizing.coolingLatent == 90) + } + } + + @Test( + arguments: SystemType.makeAirToAirTestCases(climate: .coldWinterOrNoLatentLoad) + ) + func coldWinterSizingLimits(system: SystemType) async throws { + try await withDependencies { + $0.manualS = .liveValue + } operation: { + @Dependency(\.manualS) var manualS + + let limits = try await manualS.sizingLimits(.init( + systemType: system, + houseLoad: .init(coolingTotal: 17872, coolingSensible: 13894, heating: 49667) + )) + + #expect(limits.oversizing.coolingLatent == 150) + #expect(limits.oversizing.coolingTotal == 184) + #expect(limits.undersizing.coolingTotal == 90) + #expect(limits.undersizing.coolingSensible == 90) + #expect(limits.undersizing.coolingLatent == 90) + + await #expect(throws: HouseLoadError()) { + try await manualS.sizingLimits(.init(systemType: system)) + } + } + } + + @Test( + arguments: SystemType.HeatingOnlyType.allCases + ) + func heatingOnlySizingLimits(heatingType: SystemType.HeatingOnlyType) async throws { + try await withDependencies { + $0.manualS = .liveValue + } operation: { + @Dependency(\.manualS) var manualS + + let limits = try await manualS.sizingLimits(.init( + systemType: .heatingOnly(type: heatingType), + )) + + #expect(limits.oversizing.heating == heatingType.oversizingLimit) + #expect(limits.undersizing.coolingTotal == 90) + #expect(limits.undersizing.heating == 90) + #expect(limits.undersizing.coolingSensible == 90) + #expect(limits.undersizing.coolingLatent == 90) + } + } + + @Test( + arguments: [ + (RequiredKW.Request(heatLoss: 49667), 14.55), + (RequiredKW.Request(capacityAtDesign: 11300, heatLoss: 49667), 11.24) + ] + ) + func requiredKW(request: RequiredKW.Request, expected: Double) async throws { + try await withDependencies { + $0.manualS = .liveValue + } operation: { + @Dependency(\.manualS) var manualS + + let requiredKW = try await manualS.requiredKW(request) + let rounded = round(requiredKW.requiredKW * 100) / 100 + #expect(rounded == expected) + } + } + + @Test( + arguments: [ + (elevation: 0, expected: 1.0), + (elevation: 1000, expected: 0.96), + (elevation: 2000, expected: 0.92), + (elevation: 3000, expected: 0.88), + (elevation: 4000, expected: 0.84), + (elevation: 5000, expected: 0.8), + (elevation: 6000, expected: 0.76), + (elevation: 7000, expected: 0.72), + (elevation: 8000, expected: 0.68), + (elevation: 9000, expected: 0.64), + (elevation: 10000, expected: 0.6), + (elevation: 11000, expected: 0.56), + (elevation: 12000, expected: 0.52), + (elevation: 13000, expected: 0.52) + ] + ) + func heatingDerating(elevation: Int, expected: Double) async throws { + try await withDependencies { + $0.manualS = .liveValue + } operation: { + @Dependency(\.manualS) var manualS + + for heatingType in [SystemType.HeatingOnlyType.boiler, .furnace] { + let derating = try await manualS.derating(.init( + elevation: elevation, + systemType: .heatingOnly(type: heatingType) + )) + #expect(derating.heating == expected) + } + } + } + + @Test( + arguments: [ + (elevation: 0, expected: AdjustmentMultiplier(coolingTotal: 1, coolingSensible: 1, heating: 1)), + (elevation: 1000, expected: AdjustmentMultiplier(coolingTotal: 0.99, coolingSensible: 0.97, heating: 0.98)), + (elevation: 2000, expected: AdjustmentMultiplier(coolingTotal: 0.98, coolingSensible: 0.94, heating: 0.97)), + (elevation: 3000, expected: AdjustmentMultiplier(coolingTotal: 0.98, coolingSensible: 0.91, heating: 0.95)), + (elevation: 4000, expected: AdjustmentMultiplier(coolingTotal: 0.97, coolingSensible: 0.88, heating: 0.94)), + (elevation: 5000, expected: AdjustmentMultiplier(coolingTotal: 0.96, coolingSensible: 0.85, heating: 0.92)), + (elevation: 6000, expected: AdjustmentMultiplier(coolingTotal: 0.95, coolingSensible: 0.82, heating: 0.9)), + (elevation: 7000, expected: AdjustmentMultiplier(coolingTotal: 0.94, coolingSensible: 0.8, heating: 0.89)), + (elevation: 8000, expected: AdjustmentMultiplier(coolingTotal: 0.94, coolingSensible: 0.77, heating: 0.87)), + (elevation: 9000, expected: AdjustmentMultiplier(coolingTotal: 0.93, coolingSensible: 0.74, heating: 0.86)), + (elevation: 10000, expected: AdjustmentMultiplier(coolingTotal: 0.92, coolingSensible: 0.71, heating: 0.84)), + (elevation: 11000, expected: AdjustmentMultiplier(coolingTotal: 0.91, coolingSensible: 0.68, heating: 0.82)), + (elevation: 12000, expected: AdjustmentMultiplier(coolingTotal: 0.9, coolingSensible: 0.65, heating: 0.81)), + (elevation: 13000, expected: AdjustmentMultiplier(coolingTotal: 0.9, coolingSensible: 0.65, heating: 0.81)) + ] + ) + func airToAirDerating(elevation: Int, expected: AdjustmentMultiplier) async throws { + try await withDependencies { + $0.manualS = .liveValue + } operation: { + @Dependency(\.manualS) var manualS + + let derating = try await manualS.derating(.init( + elevation: elevation, + systemType: .airToAir(type: .airConditioner, compressor: .singleSpeed, climate: .mildWinterOrLatentLoad) + )) + #expect(derating == expected) + } + } +} + +extension SystemType { + + static func makeAirToAirTestCases(climate: SystemType.ClimateType) -> [Self] { + var items: [Self] = [] + for compressor in SystemType.CompressorType.allCases { + for equipment in SystemType.EquipmentType.allCases { + items.append(.airToAir(type: equipment, compressor: compressor, climate: climate)) + } + } + return items + } + + var compressorType: SystemType.CompressorType? { + switch self { + case let .airToAir(type: _, compressor: compressor, climate: _): return compressor + case .heatingOnly: return nil + } + } +} + +extension SystemType.CompressorType { + var mildWinterTotalLimit: Int { + switch self { + case .singleSpeed: return 115 + case .multiSpeed: return 120 + case .variableSpeed: return 130 + } + } +} + +extension SystemType.HeatingOnlyType { + var oversizingLimit: Int { + switch self { + case .boiler, .furnace: return 140 + case .electric: return 175 + } + } +}