feat: Finishes economic balance point.

This commit is contained in:
2025-03-04 17:17:55 -05:00
parent 6c31a9db09
commit b58d053ba1
8 changed files with 226 additions and 15 deletions

View File

@@ -1,5 +1,5 @@
{
"originHash" : "af48ddc16f0ca460cff188345cf870056ca53edee23c016080fc1ad55f366bf9",
"originHash" : "16a33bebe1b38c2194b6ad57854e74c156212cd21d65cd12ccf6df7a6b89886c",
"pins" : [
{
"identity" : "async-http-client",

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
import CoreModels
import Foundation
import Logging
import OpcostAnalysis
import Routes
public extension HeatingBalancePoint.Request {
@@ -10,6 +11,42 @@ public extension HeatingBalancePoint.Request {
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.")
}
}
}
@@ -144,6 +181,35 @@ extension ClimateZone {
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(

View File

@@ -4,6 +4,7 @@ import Vapor
#if DEBUG
struct BrowserSyncHandler: LifecycleHandler {
func didBoot(_ application: Application) throws {
if Environment.get("BROWSER_AUTO_RELOAD") != nil {
let process = Process()
process.executableURL = URL(filePath: "/bin/sh")
process.arguments = ["-c", "browser-sync reload"]
@@ -14,4 +15,5 @@ import Vapor
}
}
}
}
#endif

View File

@@ -12,8 +12,29 @@ public enum HeatingBalancePoint {
}
public enum Request: Codable, Equatable, Sendable {
case economic(Economic)
case thermal(Thermal)
public struct Economic: Codable, Equatable, Sendable {
public let fuelType: FuelType
public let fuelCostPerUnit: Double
public let fuelAFUE: Double
public let costPerKW: Double
public init(
fuelType: HeatingBalancePoint.FuelType,
fuelCostPerUnit: Double,
fuelAFUE: Double,
costPerKW: Double
) {
self.fuelType = fuelType
self.fuelCostPerUnit = fuelCostPerUnit
self.fuelAFUE = fuelAFUE
self.costPerKW = costPerKW
}
}
public struct Thermal: Codable, Equatable, Sendable {
public let systemSize: Double
@@ -42,8 +63,32 @@ public enum HeatingBalancePoint {
}
public enum Response: Codable, Equatable, Sendable {
case economic(Economic)
case thermal(Thermal)
public struct Economic: Codable, Equatable, Sendable {
public let balancePointTemperature: Double
public let fuelCostPerMMBTU: Double
public let electricCostPerMMBTU: Double
public let copAtBalancePoint: Double
public let electricFuelRatio: Double
public init(
balancePointTemperature: Double,
fuelCostPerMMBTU: Double,
electricCostPerMMBTU: Double,
copAtBalancePoint: Double,
electricFuelRatio: Double
) {
self.balancePointTemperature = balancePointTemperature
self.fuelCostPerMMBTU = fuelCostPerMMBTU
self.electricCostPerMMBTU = electricCostPerMMBTU
self.copAtBalancePoint = copAtBalancePoint
self.electricFuelRatio = electricFuelRatio
}
}
public struct Thermal: Codable, Equatable, Sendable {
public let capacityAt47: Double
@@ -91,6 +136,26 @@ public enum HeatingBalancePoint {
}
}
}
public enum FuelType: String, CaseIterable, Codable, Equatable, Sendable {
case naturalGas
case propane
case oil
public var label: String {
switch self {
case .propane, .oil: return "\(rawValue.capitalized)"
case .naturalGas: return "Natural Gas"
}
}
public var units: String {
switch self {
case .propane, .oil: return "gallons"
case .naturalGas: return "therm"
}
}
}
}
#if DEBUG
@@ -99,7 +164,13 @@ public enum HeatingBalancePoint {
static func mock(mode: HeatingBalancePoint.Mode) -> Self {
switch mode {
case .economic:
fatalError()
return .economic(.init(
balancePointTemperature: -10.8,
fuelCostPerMMBTU: 27.6,
electricCostPerMMBTU: 38.1,
copAtBalancePoint: 2.24,
electricFuelRatio: 1.38
))
case .thermal:
return .thermal(.init(
capacityAt47: 24600,

View File

@@ -318,6 +318,17 @@ public extension SiteRoute {
Method.post
Body {
OneOf {
// Economic balance point
FormData {
Field("fuelType") { Routes.HeatingBalancePoint.FuelType.parser() }
Field("fuelCostPerUnit") { Double.parser() }
Field("fuelAFUE") { Double.parser() }
Field("costPerKW") { Double.parser() }
}
.map(.memberwise(Routes.HeatingBalancePoint.Request.Economic.init))
.map(.case(Routes.HeatingBalancePoint.Request.economic))
// Thermal Balance Point
FormData {
Field("systemSize") { Double.parser() }
Optionally { Field("capacityAt47") { Double.parser() } }

View File

@@ -41,11 +41,7 @@ struct HeatingBalancePointForm: HTML, Sendable {
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"))
}
EconomicFields()
}
div {
@@ -61,6 +57,29 @@ struct HeatingBalancePointForm: HTML, Sendable {
}
}
struct EconomicFields: HTML, Sendable {
var content: some HTML {
div(.class("grid grid-cols-1 lg:grid-cols-2 gap-4")) {
div {
InputLabel(for: "fuelType") { "Fuel Type" }
Select(for: HeatingBalancePoint.FuelType.self, id: "fuelType") { $0.label }
}
LabeledContent(label: "AFUE (%)") {
Input(id: "fuelAFUE", placeholder: "AFUE")
.attributes(.type(.number), .min("1"), .max("100"), .step("0.5"), .value("90"), .required)
}
LabeledContent(label: "Fuel Cost (gallon or therm)") {
Input(id: "fuelCostPerUnit", placeholder: "Fuel cost per unit")
.attributes(.type(.number), .min("0.1"), .step("0.01"), .value("1.33"), .required)
}
LabeledContent(label: "Electric Cost (kw/h)") {
Input(id: "costPerKW", placeholder: "Electric cost per kw/h")
.attributes(.type(.number), .min("0.01"), .step("0.01"), .value("0.13"), .required)
}
}
}
}
struct ThermalFields: HTML, Sendable {
let heatLossMode: HeatingBalancePoint.HeatLoss.Mode
@@ -175,6 +194,8 @@ struct HeatingBalancePointResponse: HTML, Sendable {
}
}
switch response {
case let .economic(result):
economicResult(result)
case let .thermal(result):
thermalResult(result)
}
@@ -184,6 +205,40 @@ struct HeatingBalancePointResponse: HTML, Sendable {
}
}
func economicResult(_ result: HeatingBalancePoint.Response.Economic) -> 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: "Electric Cost",
value: "$\(double: result.electricCostPerMMBTU, fractionDigits: 2)",
valueLabel: "/ MMBTU"
)
}
div {
VerticalGroup(
label: "Fuel Cost",
value: "$\(double: result.fuelCostPerMMBTU, fractionDigits: 2)",
valueLabel: "/ MMBTU"
)
}
}
div {
VerticalGroup(
label: "COP at Balance Point",
value: "\(double: result.copAtBalancePoint, fractionDigits: 2)"
)
}
}
}
func thermalResult(_ result: HeatingBalancePoint.Response.Thermal) -> some HTML {
div {
VerticalGroup(
@@ -239,12 +294,14 @@ private extension HeatingBalancePoint.HeatLoss.Mode {
private extension HeatingBalancePoint.Response {
var mode: HeatingBalancePoint.Mode {
switch self {
case .economic: return .economic
case .thermal: return .thermal
}
}
var warnings: [String] {
switch self {
case .economic: return []
case let .thermal(result): return result.warnings
}
}

View File

@@ -2,6 +2,10 @@ docker_image := "hvac-toolbox"
docker_tag := "latest"
docker_registiry := "registry.digitalocean.com/swift-hvac-toolbox"
[private]
default:
just --list
build-docker platform="linux/arm64":
@docker build --platform {{platform}} -t {{docker_registiry}}/{{docker_image}}:{{docker_tag}} .