225 lines
6.6 KiB
Swift
225 lines
6.6 KiB
Swift
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)
|
|
case let .economic(request):
|
|
logger.debug("Calculating economic balance point: \(request)")
|
|
return try await request.respond(logger: logger)
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension HeatingBalancePoint.Request.Economic {
|
|
|
|
func respond(logger: Logger) async throws -> HeatingBalancePoint.Response {
|
|
try validate()
|
|
let fuelCostPerMMBTU = fuelType.costPerMMBTU(afue: fuelAFUE, costPerUnit: fuelCostPerUnit)
|
|
logger.debug("Fuel cost per mmBTU: \(fuelCostPerMMBTU)")
|
|
let electricCostPerMMBTU = 1_000_000 / 3412 * costPerKW
|
|
let electricFuelRatio = electricCostPerMMBTU / fuelCostPerMMBTU
|
|
let balancePointTemperature = (electricFuelRatio - 1.8273) / 0.0413
|
|
let copAtBalancePoint = 0.0413 * balancePointTemperature + 1.8273
|
|
|
|
return .economic(.init(
|
|
balancePointTemperature: balancePointTemperature,
|
|
fuelCostPerMMBTU: fuelCostPerMMBTU,
|
|
electricCostPerMMBTU: electricCostPerMMBTU,
|
|
copAtBalancePoint: copAtBalancePoint,
|
|
electricFuelRatio: electricFuelRatio
|
|
))
|
|
}
|
|
|
|
func validate() throws {
|
|
guard fuelCostPerUnit > 0 else {
|
|
throw ValidationError(message: "Fuel cost should be greater than 0.")
|
|
}
|
|
guard fuelAFUE > 0, fuelAFUE < 100 else {
|
|
throw ValidationError(message: "AFUE is outside of range 0-100%.")
|
|
}
|
|
guard costPerKW > 0 else {
|
|
throw ValidationError(message: "Electric cost per KW should be greater than 0.")
|
|
}
|
|
}
|
|
}
|
|
|
|
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):
|
|
// TODO: Should this be 65 - designTemperature
|
|
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
|
|
}
|
|
}
|
|
|
|
var averageHeatingLoadHours: Double {
|
|
switch self {
|
|
case .one: return 1000
|
|
case .two: return 2250
|
|
case .three: return 3750
|
|
case .four: return 5250
|
|
case .five: return 6750
|
|
case .six: return 8250
|
|
case .seven: return 10000
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension HeatingBalancePoint.FuelType {
|
|
var btuPerUnit: Double {
|
|
switch self {
|
|
case .naturalGas: return 100_000
|
|
case .propane: return 91333
|
|
case .oil: return 138_690
|
|
}
|
|
}
|
|
|
|
func costPerMMBTU(afue: Double, costPerUnit: Double) -> Double {
|
|
if self == .naturalGas {
|
|
return costPerUnit * 10 / afue * 100
|
|
}
|
|
return (1_000_000 / btuPerUnit) * costPerUnit / afue * 100
|
|
}
|
|
}
|
|
|
|
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)))
|
|
}
|