diff --git a/Sources/ManualS/Extensions/NormalizePercentage.swift b/Sources/ManualS/Extensions/NormalizePercentage.swift new file mode 100644 index 0000000..a431f22 --- /dev/null +++ b/Sources/ManualS/Extensions/NormalizePercentage.swift @@ -0,0 +1,9 @@ +import CoreFoundation + +func normalizePercentage( + _ lhs: Int, + _ rhs: Int +) -> Int { + let value = Double(lhs) / Double(rhs) + return Int((value * 1000).rounded() / 10.0) +} diff --git a/Sources/ManualS/Internal/CoolingInterpolation.swift b/Sources/ManualS/Internal/CoolingInterpolation.swift index ae0ca2b..c911c16 100644 --- a/Sources/ManualS/Internal/CoolingInterpolation.swift +++ b/Sources/ManualS/Internal/CoolingInterpolation.swift @@ -129,14 +129,6 @@ private extension SizingLimits.Response { } } -private func normalizePercentage( - _ lhs: Int, - _ rhs: Int -) -> Int { - let value = Double(lhs) / Double(rhs) - return Int((value * 1000).rounded() / 10.0) -} - private extension CoolingInterpolation.InterpolationRequest { func interpolatedCapacity(outdoorDesignTemperature: Int) async -> Capacity.Cooling { diff --git a/Sources/ManualS/Internal/FurnaceInterpolation.swift b/Sources/ManualS/Internal/FurnaceInterpolation.swift new file mode 100644 index 0000000..6b8e0c6 --- /dev/null +++ b/Sources/ManualS/Internal/FurnaceInterpolation.swift @@ -0,0 +1,55 @@ +import Models +import Validations + +extension FurnaceInterpolation.Request { + private static let undersizingLimit = 90 + private static let oversizingLimit = 140 + + func respond() async throws -> FurnaceInterpolation.Response { + try await validate() + let altitudeDerating = try await altitudeDerating() + let outputCapacity = Int( + (Double(inputRating) * (altitudeDerating ?? 1.0)) + * (afue / 100) + ) + let capacityAsPercentOfLoad = normalizePercentage(outputCapacity, heatLoss) + + var failures = [String]() + if capacityAsPercentOfLoad < Self.undersizingLimit { + failures.append("Undersizing failure.") + } + if capacityAsPercentOfLoad > Self.oversizingLimit { + failures.append("Oversizing failure.") + } + + return .init( + failures: failures.isEmpty ? nil : failures, + altitudeAdjustmentMultiplier: altitudeDerating, + capacityAsPercentOfLoad: capacityAsPercentOfLoad, + oversizingLimit: Self.oversizingLimit, + undersizingLimit: Self.undersizingLimit, + outputCapacity: outputCapacity + ) + } + + func altitudeDerating() async throws -> Double? { + guard let elevation else { return nil } + let response = try await Derating.Request( + elevation: elevation, + systemType: .heatingOnly(type: .furnace) + ).respond() + return response.heating + } + +} + +extension FurnaceInterpolation.Request: AsyncValidatable { + public var body: some AsyncValidation { + AsyncValidator.accumulating { + AsyncValidator.greaterThan(\.heatLoss, 0) + AsyncValidator.greaterThan(\.inputRating, 0) + AsyncValidator.greaterThan(\.afue, 0) + AsyncValidator.lessThan(\.afue, 100) + } + } +} diff --git a/Sources/ManualS/ManualS.swift b/Sources/ManualS/ManualS.swift index 69f2d5a..77d4243 100644 --- a/Sources/ManualS/ManualS.swift +++ b/Sources/ManualS/ManualS.swift @@ -12,13 +12,20 @@ public extension DependencyValues { @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 coolingInterpolation: @Sendable (CoolingInterpolation.Request) async throws -> CoolingInterpolation.Response + + public var furnaceInterpolation: + @Sendable (FurnaceInterpolation.Request) async throws -> FurnaceInterpolation.Response + public var heatPumpHeatingInterpolation: @Sendable (HeatPumpHeatingInterpolation.Request) async throws -> HeatPumpHeatingInterpolation.Response public var requiredKW: @Sendable (RequiredKW.Request) async throws -> RequiredKW.Response + public var sizingLimits: @Sendable (SizingLimits.Request) async throws -> SizingLimits.Response } @@ -27,6 +34,7 @@ extension ManualS: DependencyKey { balancePoint: { try await $0.respond() }, derating: { try await $0.respond() }, coolingInterpolation: { try await $0.respond() }, + furnaceInterpolation: { try await $0.respond() }, heatPumpHeatingInterpolation: { try await $0.respond() }, requiredKW: { try await $0.respond() }, sizingLimits: { try await $0.respond() } diff --git a/Sources/Models/FurnaceInterpolation.swift b/Sources/Models/FurnaceInterpolation.swift new file mode 100644 index 0000000..1aee7d9 --- /dev/null +++ b/Sources/Models/FurnaceInterpolation.swift @@ -0,0 +1,53 @@ +public enum FurnaceInterpolation { + + public struct Request: Codable, Equatable, Sendable { + + public let elevation: Int? + public let winterDesignTemperature: Int + public let heatLoss: Int + public let inputRating: Int + public let afue: Double + + public init( + elevation: Int? = nil, + winterDesignTemperature: Int, + heatLoss: Int, + inputRating: Int, + afue: Double + ) { + self.elevation = elevation + self.winterDesignTemperature = winterDesignTemperature + self.heatLoss = heatLoss + self.inputRating = inputRating + self.afue = afue + } + } + + public struct Response: Codable, Equatable, Sendable { + + public let failed: Bool + public let failures: [String]? + public let altitudeAdjustmentMultiplier: Double? + public let capacityAsPercentOfLoad: Int + public let oversizingLimit: Int + public let undersizingLimit: Int + public let outputCapacity: Int + + public init( + failures: [String]? = nil, + altitudeAdjustmentMultiplier: Double? = nil, + capacityAsPercentOfLoad: Int, + oversizingLimit: Int, + undersizingLimit: Int, + outputCapacity: Int + ) { + self.failed = failures == nil ? false : !failures!.isEmpty + self.failures = failures + self.altitudeAdjustmentMultiplier = altitudeAdjustmentMultiplier + self.capacityAsPercentOfLoad = capacityAsPercentOfLoad + self.oversizingLimit = oversizingLimit + self.undersizingLimit = undersizingLimit + self.outputCapacity = outputCapacity + } + } +} diff --git a/Tests/ManualSTests/ManualSTests.swift b/Tests/ManualSTests/ManualSTests.swift index fa1a564..3a1200b 100644 --- a/Tests/ManualSTests/ManualSTests.swift +++ b/Tests/ManualSTests/ManualSTests.swift @@ -159,6 +159,51 @@ struct ManualSTests { } } + @Test + func furnaceInterpolation() async throws { + try await withDependencies { + $0.manualS = .liveValue + } operation: { + @Dependency(\.manualS) var manualS + + let response = try await manualS.furnaceInterpolation( + .init( + elevation: nil, + winterDesignTemperature: 5, + heatLoss: 49667, + inputRating: 60000, + afue: 96 + ) + ) + + #expect(response.failed == false) + #expect(response.capacityAsPercentOfLoad == 116) + #expect(response.outputCapacity == 57600) + } + } + + @Test + func furnaceInterpolation_with_oversizedFurnace() async throws { + try await withDependencies { + $0.manualS = .liveValue + } operation: { + @Dependency(\.manualS) var manualS + + let response = try await manualS.furnaceInterpolation( + .init( + elevation: nil, + winterDesignTemperature: 5, + heatLoss: 49667, + inputRating: 160_000, + afue: 96 + ) + ) + + #expect(response.failed) + #expect(response.failures == ["Oversizing failure."]) + } + } + @Test func balancePoint() async throws { try await withDependencies {