From c1741de3f95bdf70e59ec0f9a468435cdc45fce9 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Fri, 24 May 2024 16:44:41 -0400 Subject: [PATCH] feat: Initial commit --- .gitignore | 9 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + Package.swift | 42 +++ Sources/EstimatedPressureDependency/Key.swift | 248 +++++++++++++++++ .../BudgetedPercentEnvelope.swift | 54 ++++ Sources/SharedModels/CoolingCapacity.swift | 16 ++ .../SharedModels/EquipmentMeasurement.swift | 117 ++++++++ Sources/SharedModels/EquipmentType.swift | 16 ++ Sources/SharedModels/FanType.swift | 15 ++ Sources/SharedModels/Flagged.swift | 254 ++++++++++++++++++ .../FlaggedEquipmentMeasurement.swift | 225 ++++++++++++++++ .../HelperTypes/Optional+Numeric.swift | 119 ++++++++ .../SharedModels/HelperTypes/Percentage.swift | 93 +++++++ .../SharedModels/HelperTypes/Positive.swift | 87 ++++++ Sources/SharedModels/PlenumDimension.swift | 33 +++ Sources/SharedModels/RatedEnvelope.swift | 50 ++++ Sources/SharedModels/Velocity.swift | 18 ++ .../EstimatedPressureTests.swift | 94 +++++++ .../PercentageTests.swift | 62 +++++ 19 files changed, 1560 insertions(+) create mode 100644 .gitignore create mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Package.swift create mode 100644 Sources/EstimatedPressureDependency/Key.swift create mode 100644 Sources/SharedModels/BudgetedPercentEnvelope.swift create mode 100644 Sources/SharedModels/CoolingCapacity.swift create mode 100644 Sources/SharedModels/EquipmentMeasurement.swift create mode 100644 Sources/SharedModels/EquipmentType.swift create mode 100644 Sources/SharedModels/FanType.swift create mode 100644 Sources/SharedModels/Flagged.swift create mode 100644 Sources/SharedModels/FlaggedEquipmentMeasurement.swift create mode 100644 Sources/SharedModels/HelperTypes/Optional+Numeric.swift create mode 100644 Sources/SharedModels/HelperTypes/Percentage.swift create mode 100644 Sources/SharedModels/HelperTypes/Positive.swift create mode 100644 Sources/SharedModels/PlenumDimension.swift create mode 100644 Sources/SharedModels/RatedEnvelope.swift create mode 100644 Sources/SharedModels/Velocity.swift create mode 100644 Tests/EstimatedPressureTests/EstimatedPressureTests.swift create mode 100644 Tests/EstimatedPressureTests/PercentageTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47bd4c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +Package.resolved diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..653ce0f --- /dev/null +++ b/Package.swift @@ -0,0 +1,42 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "swift-estimated-pressures-core", + platforms: [ + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v6), + ], + products: [ + .library(name: "EstimatedPressureDependency", targets: ["EstimatedPressureDependency"]), + .library(name: "SharedModels", targets: ["SharedModels"]), + + ], + dependencies: [ + .package( + url: "https://github.com/pointfreeco/swift-dependencies.git", + from: "1.3.0" + ), + ], + targets: [ + .target( + name: "EstimatedPressureDependency", + dependencies: [ + "SharedModels", + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies") + ] + ), + .target(name: "SharedModels"), + .testTarget( + name: "EstimatedPressureTests", + dependencies: [ + "EstimatedPressureDependency", + "SharedModels" + ] + ), + ] +) diff --git a/Sources/EstimatedPressureDependency/Key.swift b/Sources/EstimatedPressureDependency/Key.swift new file mode 100644 index 0000000..882e39d --- /dev/null +++ b/Sources/EstimatedPressureDependency/Key.swift @@ -0,0 +1,248 @@ +import Dependencies +import DependenciesMacros +import Foundation +@_exported import SharedModels + +extension DependencyValues { + + public var estimatedPressuresClient: EstimatedPressureDependency { + get { self[EstimatedPressureDependency.self] } + set { self[EstimatedPressureDependency.self] = newValue } + } +} + +@DependencyClient +public struct EstimatedPressureDependency { + + public var estimatedAirflow: (EstimatedAirflowRequest) async throws -> Positive + public var estimatedPressure: (EstimatedPressureRequest) async throws -> Positive + + public struct EstimatedPressureRequest: Equatable { + public let existingPressure: Positive + public let existingAirflow: Positive + public let targetAirflow: Positive + + public init( + existingPressure: Double, + existingAirflow: Double, + targetAirflow: Double + ) { + self.existingPressure = .init(wrappedValue: existingPressure) + self.existingAirflow = .init(wrappedValue: existingAirflow) + self.targetAirflow = .init(wrappedValue: targetAirflow) + } + } + + public struct EstimatedAirflowRequest: Equatable { + public let existingAirflow: Positive + public let existingPressure: Positive + public let targetPressure: Positive + + public init( + existingAirflow: Double, + existingPressure: Double, + targetPressure: Double + ) { + self.existingPressure = .init(wrappedValue: existingPressure) + self.existingAirflow = .init(wrappedValue: existingAirflow) + self.targetPressure = .init(wrappedValue: targetPressure) + } + } + + public func estimatedPressure( + existingPressure: Double, + existingAirflow: Double, + targetAirflow: Double + ) async throws -> Positive { + try await self.estimatedPressure( + .init( + existingPressure: existingPressure, + existingAirflow: existingAirflow, + targetAirflow: targetAirflow + ) + ) + } +} + +extension EstimatedPressureDependency { + private func estimatedPressure( + existingPressure: Positive, + existingAirflow: Double, + targetAirflow: Double + ) async throws -> Positive { + try await self.estimatedPressure( + .init( + existingPressure: existingPressure.positiveValue ?? 0, + existingAirflow: existingAirflow, + targetAirflow: targetAirflow + ) + ) + } + + public func estimatedPressure( + for equipmentMeasurement: EquipmentMeasurement, + at upgradedAirflow: Double + ) async throws -> EquipmentMeasurement { + switch equipmentMeasurement { + case let .airHandler(airHandler): + guard let airflow = airHandler.airflow else { + throw InvalidAirflow() + } + return try await .airHandler( + .init( + airflow: upgradedAirflow, + returnPlenumPressure: self.estimatedPressure( + existingPressure: airHandler.$returnPlenumPressure, + existingAirflow: airflow, + targetAirflow: upgradedAirflow + ), + postFilterPressure: self.estimatedPressure( + existingPressure: airHandler.$postFilterPressure, + existingAirflow: airflow, + targetAirflow: upgradedAirflow + ), + postCoilPressure: self.estimatedPressure( + existingPressure: airHandler.$postCoilPressure, + existingAirflow: airflow, + targetAirflow: upgradedAirflow + ), + supplyPlenumPressure: self.estimatedPressure( + existingPressure: airHandler.$supplyPlenumPressure, + existingAirflow: airflow, + targetAirflow: upgradedAirflow + ) + ) + ) + + case let .furnaceAndCoil(furnaceAndCoil): + guard let airflow = furnaceAndCoil.airflow else { + throw InvalidAirflow() + } + return try await .furnaceAndCoil( + .init( + airflow: upgradedAirflow, + returnPlenumPressure: self.estimatedPressure( + existingPressure: furnaceAndCoil.$returnPlenumPressure, + existingAirflow: airflow, + targetAirflow: upgradedAirflow + ), + postFilterPressure: self.estimatedPressure( + existingPressure: furnaceAndCoil.$postFilterPressure, + existingAirflow: airflow, + targetAirflow: upgradedAirflow + ), + preCoilPressure: self.estimatedPressure( + existingPressure: furnaceAndCoil.$preCoilPressure, + existingAirflow: airflow, + targetAirflow: upgradedAirflow + ), + supplyPlenumPressure: self.estimatedPressure( + existingPressure: furnaceAndCoil.$supplyPlenumPressure, + existingAirflow: airflow, + targetAirflow: upgradedAirflow + ) + ) + ) + + } + } + + public func estimatedPressure( + for equipmentMeasurement: EquipmentMeasurement, + at upgradedAirflow: Double, + with filterPressureDrop: Positive + ) async throws -> EquipmentMeasurement { + let estimate = try await estimatedPressure( + for: equipmentMeasurement, + at: upgradedAirflow + ) + + switch estimate { + + case var .airHandler(airHandler): + airHandler.postFilterPressure = airHandler.returnPlenumPressure + filterPressureDrop.positiveValue + return .airHandler(airHandler) + + case var .furnaceAndCoil(furnaceAndCoil): + furnaceAndCoil.postFilterPressure = furnaceAndCoil.returnPlenumPressure + filterPressureDrop.positiveValue + return .furnaceAndCoil(furnaceAndCoil) + } + + + } +} + +extension EstimatedPressureDependency: DependencyKey { + + public static var testValue: EstimatedPressureDependency { Self() } + + public static var liveValue: EstimatedPressureDependency { + .init( + estimatedAirflow: { request in + guard request.existingPressure.positiveValue > 0 else { + throw LessThanZeroError.existingPressure + } + let value = request.existingAirflow.positiveValue * sqrt( + request.targetPressure.positiveValue / request.existingPressure.positiveValue + ) + return .init(wrappedValue: value) + }, + estimatedPressure: { request in + guard request.existingAirflow.positiveValue > 0 else { + throw LessThanZeroError.existingAirflow + } + + let value = pow( + request.targetAirflow.positiveValue / request.existingAirflow.positiveValue, + 2 + ) * request.existingPressure.positiveValue + + return .init(wrappedValue: value) + } + ) + } +} + +fileprivate extension EquipmentMeasurement.AirHandler { + init( + airflow: Double? = nil, + returnPlenumPressure: Positive, + postFilterPressure: Positive, + postCoilPressure: Positive, + supplyPlenumPressure: Positive + ) { + self.init( + airflow: airflow, + returnPlenumPressure: returnPlenumPressure.positiveValue, + postFilterPressure: postFilterPressure.positiveValue, + postCoilPressure: postCoilPressure.positiveValue, + supplyPlenumPressure: supplyPlenumPressure.positiveValue + ) + } +} + +fileprivate extension EquipmentMeasurement.FurnaceAndCoil { + init( + airflow: Double? = nil, + returnPlenumPressure: Positive, + postFilterPressure: Positive, + preCoilPressure: Positive, + supplyPlenumPressure: Positive + ) { + self.init( + airflow: airflow, + returnPlenumPressure: returnPlenumPressure.positiveValue, + postFilterPressure: postFilterPressure.positiveValue, + preCoilPressure: preCoilPressure.positiveValue, + supplyPlenumPressure: supplyPlenumPressure.positiveValue + ) + } +} + +struct InvalidAirflow: Error { } + +enum LessThanZeroError: Error { + case existingAirflow + case existingPressure +} + diff --git a/Sources/SharedModels/BudgetedPercentEnvelope.swift b/Sources/SharedModels/BudgetedPercentEnvelope.swift new file mode 100644 index 0000000..5651bb5 --- /dev/null +++ b/Sources/SharedModels/BudgetedPercentEnvelope.swift @@ -0,0 +1,54 @@ +import Foundation + +public struct BudgetedPercentEnvelope: Equatable { + + public var coilBudget: Percentage + public var filterBudget: Percentage + public var returnPlenumBudget: Percentage + public var supplyPlenumBudget: Percentage + + public var preCoilBudget: Percentage { + coilBudget + supplyPlenumBudget + } + + public init( + coilBudget: Percentage, + filterBudget: Percentage, + returnPlenumBudget: Percentage, + supplyPlenumBudget: Percentage + ) { + self.coilBudget = coilBudget + self.filterBudget = filterBudget + self.returnPlenumBudget = returnPlenumBudget + self.supplyPlenumBudget = supplyPlenumBudget + } + + public init(equipmentType: EquipmentType, fanType: FanType) { + switch equipmentType { + case .furnaceAndCoil: + switch fanType { + case .constantSpeed: + self.init( + coilBudget: 40%, + filterBudget: 20%, + returnPlenumBudget: 20%, + supplyPlenumBudget: 20% + ) + case .variableSpeed: + self.init( + coilBudget: 30%, + filterBudget: 40%, + returnPlenumBudget: 15%, + supplyPlenumBudget: 15% + ) + } + case .airHandler: + self.init( + coilBudget: .zero, + filterBudget: 50%, + returnPlenumBudget: 25%, + supplyPlenumBudget: 25% + ) + } + } +} diff --git a/Sources/SharedModels/CoolingCapacity.swift b/Sources/SharedModels/CoolingCapacity.swift new file mode 100644 index 0000000..0f33764 --- /dev/null +++ b/Sources/SharedModels/CoolingCapacity.swift @@ -0,0 +1,16 @@ +import Foundation + +public enum CoolingCapacity: Double, Equatable, CaseIterable { + case half = 0.5 + case threeQuarter = 0.75 + case one = 1 + case oneAndAHalf = 1.5 + case two = 2 + case twoAndAHalf = 2.5 + case three = 3 + case threeAndAHalf = 3.5 + case four = 4 + case five = 5 + + public static var `default`: Self { .three } +} diff --git a/Sources/SharedModels/EquipmentMeasurement.swift b/Sources/SharedModels/EquipmentMeasurement.swift new file mode 100644 index 0000000..c18e60e --- /dev/null +++ b/Sources/SharedModels/EquipmentMeasurement.swift @@ -0,0 +1,117 @@ +import Foundation + +public enum EquipmentMeasurement: Equatable { + + case airHandler(AirHandler) + case furnaceAndCoil(FurnaceAndCoil) + + public var equipmentType: EquipmentType { + switch self { + case .airHandler: + return .airHandler + case .furnaceAndCoil: + return .furnaceAndCoil + } + } + + public var externalStaticPressure: Double { + switch self { + case let .airHandler(airHandler): + return airHandler.externalStaticPressure + case let .furnaceAndCoil(furnaceAndCoil): + return furnaceAndCoil.externalStaticPressure + } + } + + public struct AirHandler: Equatable { + + public var airflow: Double? + + @Positive + public var returnPlenumPressure: Double? + + @Positive + public var postFilterPressure: Double? + + @Positive + public var postCoilPressure: Double? + + @Positive + public var supplyPlenumPressure: Double? + + public init( + airflow: Double? = nil, + returnPlenumPressure: Double? = nil, + postFilterPressure: Double? = nil, + postCoilPressure: Double? = nil, + supplyPlenumPressure: Double? = nil + ) { + self.airflow = airflow + self.returnPlenumPressure = returnPlenumPressure + self.postFilterPressure = postFilterPressure + self.postCoilPressure = postCoilPressure + self.supplyPlenumPressure = supplyPlenumPressure + } + + public var externalStaticPressure: Double { + ($returnPlenumPressure.positiveValue ?? 0) + ($supplyPlenumPressure.positiveValue ?? 0) + } + } + + public struct FurnaceAndCoil: Equatable { + + public var airflow: Double? + + @Positive + public var returnPlenumPressure: Double? + + @Positive + public var postFilterPressure: Double? + + @Positive + public var preCoilPressure: Double? + + @Positive + public var supplyPlenumPressure: Double? + + public init( + airflow: Double? = nil, + returnPlenumPressure: Double? = nil, + postFilterPressure: Double? = nil, + preCoilPressure: Double? = nil, + supplyPlenumPressure: Double? = nil + ) { + self.airflow = airflow + self.returnPlenumPressure = returnPlenumPressure + self.postFilterPressure = postFilterPressure + self.preCoilPressure = preCoilPressure + self.supplyPlenumPressure = supplyPlenumPressure + } + + public var externalStaticPressure: Double { + ($postFilterPressure.positiveValue ?? 0) + ($preCoilPressure.positiveValue ?? 0) + } + } +} + +extension EquipmentMeasurement.AirHandler { + + public enum Key: String, Equatable, CaseIterable { + case returnPlenumPressure + case postFilterPressure + case postCoilPressure + case supplyPlenumPressure + case airflow + } +} + +extension EquipmentMeasurement.FurnaceAndCoil { + + public enum Key: String, Equatable, CaseIterable { + case returnPlenumPressure + case postFilterPressure + case preCoilPressure + case supplyPlenumPressure + case airflow + } +} diff --git a/Sources/SharedModels/EquipmentType.swift b/Sources/SharedModels/EquipmentType.swift new file mode 100644 index 0000000..5665784 --- /dev/null +++ b/Sources/SharedModels/EquipmentType.swift @@ -0,0 +1,16 @@ +import Foundation + +public enum EquipmentType: Equatable, CaseIterable, CustomStringConvertible { + case airHandler + case furnaceAndCoil + + public var description: String { + switch self { + case .airHandler: + return "Air Handler" + + case .furnaceAndCoil: + return "Furnace & Coil" + } + } +} diff --git a/Sources/SharedModels/FanType.swift b/Sources/SharedModels/FanType.swift new file mode 100644 index 0000000..f2ae384 --- /dev/null +++ b/Sources/SharedModels/FanType.swift @@ -0,0 +1,15 @@ +import Foundation + +public enum FanType: Equatable, CaseIterable, CustomStringConvertible { + case constantSpeed + case variableSpeed + + public var description: String { + switch self { + case .constantSpeed: + return "Constant Speed" + case .variableSpeed: + return "Variable Speed" + } + } +} diff --git a/Sources/SharedModels/Flagged.swift b/Sources/SharedModels/Flagged.swift new file mode 100644 index 0000000..cc1eabb --- /dev/null +++ b/Sources/SharedModels/Flagged.swift @@ -0,0 +1,254 @@ +import Foundation + +public enum FlaggedValue { + case good(Value, String?) + case warning(Value, String?) + case error(Value, String?) + + public init(_ value: Value, key: Key, message: String? = nil) { + switch key { + case .good: + self = .good(value, message) + case .warning: + self = .warning(value, message) + case .error: + self = .error(value, message) + } + } + + public var key: Key { + switch self { + case .good: + return .good + case .warning: + return .warning + case .error: + return .error + } + } + + public var value: Value { + switch self { + case let .good(value, _): + return value + case let .warning(value, _): + return value + case let .error(value, _): + return value + } + } + + public var message: String? { + switch self { + case let .good(_, message): + return message + case let .warning(_, message): + return message + case let .error(_, message): + return message + } + } + + public enum Key: String, Equatable, CaseIterable { + case good, warning, error + + public var title: String { + self.rawValue.capitalized + } + } +} + +extension FlaggedValue: Equatable where Value: Equatable { } + +@dynamicMemberLookup +public struct Flagged: Equatable { + + public var checkValue: CheckHandler + public var wrappedValue: Double + + public init( + wrappedValue: Double, + _ handler: CheckHandler + ) { + self.checkValue = handler + self.wrappedValue = wrappedValue + } + + public init( + wrappedValue: Double, + _ checkValue: @escaping (Double) -> CheckResult + ) { + self.checkValue = .init(checkValue) + self.wrappedValue = wrappedValue + } + + public static func error(_ wrappedValue: Double? = nil, message: String) -> Self { + self.init(wrappedValue: wrappedValue ?? 0) { _ in + return .error(message) + } + } + + public var projectedValue: FlaggedValue { + let checkedResult = checkValue(wrappedValue) + let key = checkedResult.key + let message: String? + + switch checkedResult { + + case let .aboveMaximum(max): + message = "Above maximum: \(doubleString(max))" + + case let .belowMinimum(min): + message = "Below minimum: \(doubleString(min))" + + case .betweenRange(minimum: let minimum, maximum: let maximum): + message = "Between: \(minimum) and \(maximum)" + + case .betweenRatedAndMaximum(rated: let rated, maximum: let maximum): + message = "Between rated: \(doubleString(rated)) and maximum: \(doubleString(maximum))" + case let .good(goodMessage): + message = goodMessage + + case let .error(errorMessage): + message = errorMessage + } + + return .init(wrappedValue, key: key, message: message) + } + + public struct GoodMessageHandler { + let message: (Double) -> String? + + public init(message: @escaping (Double) -> String?) { + self.message = message + } + + public func callAsFunction(_ double: Double) -> String? { + self.message(double) + } + + public static var none: Self { + .init { _ in nil } + } + } + + public struct CheckHandler { + + let checkValue: (Double) -> CheckResult + + public init(_ checkValue: @escaping (Double) -> CheckResult) { + self.checkValue = checkValue + } + + public func callAsFunction(_ value: Double) -> CheckResult { + self.checkValue(value) + } + } + + public enum CheckResult: Equatable { + + case aboveMaximum(Double) + case belowMinimum(Double) + case betweenRange(minimum: Double, maximum: Double) + case betweenRatedAndMaximum(rated: Double, maximum: Double) + case good(String? = nil) + case error(String) + + var key: FlaggedValue.Key { + switch self { + case .aboveMaximum(_): + return .error + case .belowMinimum(_): + return .error + case .betweenRange: + return .warning + case .betweenRatedAndMaximum: + return .warning + case .good(_): + return .good + case .error(_): + return .error + } + } + } + + public static func == (lhs: Flagged, rhs: Flagged) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } + + public subscript(dynamicMember keyPath: KeyPath, T>) -> T { + self.projectedValue[keyPath: keyPath] + } +} + +// MARK: - Check Handlers +extension Flagged.CheckHandler { + public static func using( + maximum: Double, + minimum: Double, + rated: Double, + goodMessage: Flagged.GoodMessageHandler = .none + ) -> Self { + .init { value in + if value < minimum { + return .belowMinimum(minimum) + } else if value > maximum { + return .aboveMaximum(maximum) + } else if value > rated { + return .betweenRatedAndMaximum(rated: rated, maximum: maximum) + } else { + return .good(goodMessage(value)) + } + } + } + + public static func airflow( + tons: CoolingCapacity, + ratings: RatedAirflowPerTon = .init(), + goodMessage: Flagged.GoodMessageHandler? = nil + ) -> Self { + .rated(RatedAirflowLimits(tons: tons, using: ratings), goodMessage: goodMessage) + } + + public static func rated( + _ ratings: RatedEnvelope, + goodMessage: Flagged.GoodMessageHandler? = nil + ) -> Self { + .using( + maximum: ratings.maximum, + minimum: ratings.minimum, + rated: ratings.rated, + goodMessage: goodMessage ?? .none + ) + } + + public static func result(_ result: Flagged.CheckResult) -> Self { + self.init { _ in result } + } + + public static func velocity( + goodMessage: Flagged.GoodMessageHandler = .none + ) -> Self { + .init { value in + if value <= 700 { + return .good(goodMessage(value)) + } else if value < 900 { + return .betweenRange(minimum: 700, maximum: 900) + } else { + return .error("Velocity greater than 900 FPM") + } + } + } +} + +// MARK: - Helpers +fileprivate let formatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 2 + return formatter +}() + +fileprivate func doubleString(_ double: Double) -> String { + return formatter.string(for: double) ?? "\(double)" +} diff --git a/Sources/SharedModels/FlaggedEquipmentMeasurement.swift b/Sources/SharedModels/FlaggedEquipmentMeasurement.swift new file mode 100644 index 0000000..fac2558 --- /dev/null +++ b/Sources/SharedModels/FlaggedEquipmentMeasurement.swift @@ -0,0 +1,225 @@ +import Foundation + +// TODO: Needs updated for when forms are using `minmal` values. +public struct FlaggedEquipmentMeasurement: Equatable { + public var airflow: Flagged + public var coilPressureDrop: Flagged + public var externalStaticPressure: Flagged + public var filterPressureDrop: Flagged + public var returnPlenumPressure: Flagged + public var supplyPlenumPressure: Flagged + + public init( + airflow: Flagged, + coilPressureDrop: Flagged, + externalStaticPressure: Flagged, + filterPressureDrop: Flagged, + returnPlenumPressure: Flagged, + supplyPlenumPressure: Flagged + ) { + self.airflow = airflow + self.coilPressureDrop = coilPressureDrop + self.externalStaticPressure = externalStaticPressure + self.filterPressureDrop = filterPressureDrop + self.returnPlenumPressure = returnPlenumPressure + self.supplyPlenumPressure = supplyPlenumPressure + } + + public init( + budgets: BudgetedPercentEnvelope, + measurement: EquipmentMeasurement, + ratedPressures: RatedStaticPressures, + tons: CoolingCapacity? + ) { + switch measurement { + case let .airHandler(airHandler): + self = .airHandler( + budgets: budgets, + measurement: airHandler, + ratedPressures: ratedPressures, + tons: tons + ) + case let .furnaceAndCoil(furnaceAndCoil): + self = .furnaceAndCoil( + budgets: budgets, + measurement: furnaceAndCoil, + ratedPressures: ratedPressures, + tons: tons + ) + } + } + + public static func airHandler( + budgets: BudgetedPercentEnvelope, + measurement: EquipmentMeasurement.AirHandler, + ratedPressures: RatedStaticPressures, + tons: CoolingCapacity? + ) -> Self { + .init( + airflow: checkAirflow(value: measurement.airflow, tons: tons), + coilPressureDrop: .init( + wrappedValue: (measurement.$postCoilPressure.positiveValue - measurement.$postFilterPressure.positiveValue) ?? 0, + .result(.good()) + ), + externalStaticPressure: checkExternalStaticPressure( + value: measurement.externalStaticPressure, + ratedPressures: ratedPressures + ), + filterPressureDrop: .init( + value: measurement.$postFilterPressure.positiveValue - measurement.$returnPlenumPressure.positiveValue, + budget: budgets.filterBudget, + ratedPressures: ratedPressures + ), + returnPlenumPressure: .init( + value: measurement.$returnPlenumPressure.positiveValue, + budget: budgets.returnPlenumBudget, + ratedPressures: ratedPressures + ), + supplyPlenumPressure: .init( + value: measurement.$supplyPlenumPressure.positiveValue ?? 0, + budget: budgets.supplyPlenumBudget, + ratedPressures: ratedPressures + ) + ) + } + + public static func furnaceAndCoil( + budgets: BudgetedPercentEnvelope, + measurement: EquipmentMeasurement.FurnaceAndCoil, + ratedPressures: RatedStaticPressures, + tons: CoolingCapacity? + ) -> Self { + .init( + airflow: checkAirflow(value: measurement.airflow, tons: tons), + coilPressureDrop: .init( + value: measurement.$preCoilPressure.positiveValue - measurement.$supplyPlenumPressure.positiveValue, + budget: budgets.coilBudget, + ratedPressures: ratedPressures + ), + externalStaticPressure: checkExternalStaticPressure( + value: measurement.externalStaticPressure, + ratedPressures: ratedPressures + ), + filterPressureDrop: .init( + value: measurement.$postFilterPressure.positiveValue - measurement.$returnPlenumPressure.positiveValue, + budget: budgets.filterBudget, + ratedPressures: ratedPressures, + ignoreMinimum: true + ), + returnPlenumPressure: .init( + value: measurement.$returnPlenumPressure.positiveValue, + budget: budgets.returnPlenumBudget, + ratedPressures: ratedPressures + ), + supplyPlenumPressure: .init( + value: measurement.$supplyPlenumPressure.positiveValue, + budget: budgets.supplyPlenumBudget, + ratedPressures: ratedPressures + ) + ) + } +} + +// MARK: - Key +extension FlaggedEquipmentMeasurement { + // NOTE: These need to be kept in display order. + public enum Key: Equatable, CaseIterable { + case returnPlenum + case filterDrop + case coilDrop + case supplyPlenum + case staticPressure + case airflow + + public var title: String { + switch self { + case .returnPlenum: + return "Return Plenum" + case .filterDrop: + return "Filter Pressure Drop" + case .coilDrop: + return "Coil Pressure Drop" + case .supplyPlenum: + return "Supply Plenum" + case .staticPressure: + return "External Static Pressure" + case .airflow: + return "System Airflow" + } + } + + public var flaggedKeyPath: KeyPath { + switch self { + case .returnPlenum: + return \.returnPlenumPressure + case .filterDrop: + return \.filterPressureDrop + case .coilDrop: + return \.coilPressureDrop + case .supplyPlenum: + return \.supplyPlenumPressure + case .staticPressure: + return \.externalStaticPressure + case .airflow: + return \.airflow + } + } + } +} + +// MARK: - Helpers +fileprivate extension Flagged { + + init( + value: Double, + budget: Percentage, + ratedPressures: RatedStaticPressures, + ignoreMinimum: Bool = false + ) { + let minimum = ignoreMinimum ? 0 : ratedPressures.minimum * budget.fraction + let maximum = ratedPressures.maximum * budget.fraction + let rated = ratedPressures.rated * budget.fraction + self.init( + wrappedValue: value, + .using(maximum: maximum, minimum: minimum, rated: rated) + ) + } + + init( + value: Double?, + budget: Percentage, + ratedPressures: RatedStaticPressures, + ignoreMinimum: Bool = false + ) { + guard let value else { + self = .error(message: "Value is not set.") + return + } + self.init( + value: value, + budget: budget, + ratedPressures: ratedPressures, + ignoreMinimum: ignoreMinimum + ) + } +} + +fileprivate func checkExternalStaticPressure( + value: Double, + ratedPressures: RatedStaticPressures +) -> Flagged { + .init( + wrappedValue: value, + .rated(ratedPressures) + ) +} + +fileprivate func checkAirflow( + value: Double?, + tons: CoolingCapacity? +) -> Flagged { + guard let value, let tons else { + return .init(wrappedValue: value ?? 0, .result(.good())) + } + return .init(wrappedValue: value, .airflow(tons: tons)) +} diff --git a/Sources/SharedModels/HelperTypes/Optional+Numeric.swift b/Sources/SharedModels/HelperTypes/Optional+Numeric.swift new file mode 100644 index 0000000..2f14698 --- /dev/null +++ b/Sources/SharedModels/HelperTypes/Optional+Numeric.swift @@ -0,0 +1,119 @@ +import Foundation + +extension Optional: ExpressibleByIntegerLiteral where Wrapped: ExpressibleByIntegerLiteral { + + public typealias IntegerLiteralType = Wrapped.IntegerLiteralType + + public init(integerLiteral value: Wrapped.IntegerLiteralType) { + self = .some(.init(integerLiteral: value)) + } +} + +extension Optional: ExpressibleByFloatLiteral where Wrapped: ExpressibleByFloatLiteral { + public typealias FloatLiteralType = Wrapped.FloatLiteralType + + public init(floatLiteral value: Wrapped.FloatLiteralType) { + self = .some(.init(floatLiteral: value)) + } +} + +extension Optional: AdditiveArithmetic where Wrapped: AdditiveArithmetic { + public static func - (lhs: Optional, rhs: Optional) -> Optional { + switch (lhs, rhs) { + + case let (.some(lhs), .some(rhs)): + return .some(lhs - rhs) + + case let (.some(lhs), .none): + return .some(lhs - .zero) + + case let (.none, .some(rhs)): + return .some(.zero - rhs) + + case (.none, .none): + return .none + } + } + + public static func + (lhs: Optional, rhs: Optional) -> Optional { + switch (lhs, rhs) { + + case let (.some(lhs), .some(rhs)): + return .some(lhs + rhs) + + case let (.some(lhs), .none): + return .some(lhs + .zero) + + case let (.none, .some(rhs)): + return .some(.zero + rhs) + + case (.none, .none): + return .none + } + } + + public static var zero: Optional { + .some(Wrapped.zero) + } + +} + +extension Optional: Numeric where Wrapped: Numeric { + + public static func *= (lhs: inout Optional, rhs: Optional) { + switch (lhs, rhs) { + + case let (.some(lhsValue), .some(rhsValue)): + lhs = lhsValue * rhsValue + + case (.some, .none), + (.none, .some), + (.none, .none): + lhs = .none + + } + } + + public init?(exactly source: T) where T : BinaryInteger { + if let wrappedValue = Wrapped(exactly: source) { + self = .some(wrappedValue) + } else { + self = .none + } + } + + public var magnitude: Wrapped.Magnitude { + switch self { + case .none: + return Self.zero!.magnitude + case let .some(value): + return value.magnitude + } + } + + public static func * (lhs: Optional, rhs: Optional) -> Optional { + switch (lhs, rhs) { + + case let (.some(lhs), .some(rhs)): + return .some(lhs * rhs) + + case (.some, .none), + (.none, .some), + (.none, .none): + return .none + } + } + + public typealias Magnitude = Wrapped.Magnitude +} + +extension Optional: Comparable where Wrapped: Comparable { + public static func < (lhs: Optional, rhs: Optional) -> Bool { + switch (lhs, rhs) { + case let (.some(lhs), .some(rhs)): + return lhs < rhs + default: + return false + } + } +} diff --git a/Sources/SharedModels/HelperTypes/Percentage.swift b/Sources/SharedModels/HelperTypes/Percentage.swift new file mode 100644 index 0000000..09529b0 --- /dev/null +++ b/Sources/SharedModels/HelperTypes/Percentage.swift @@ -0,0 +1,93 @@ +import Foundation + +public struct Percentage: Equatable, RawRepresentable { + + public var rawValue: Double + + public init(rawValue: Double) { + self.rawValue = rawValue + } + + public init(fraction: Double) { + self.rawValue = fraction * 100 + } + + public var fraction: Double { + self.rawValue / 100 + } +} + +extension Percentage: ExpressibleByFloatLiteral { + public typealias FloatLiteralType = Double + + public init(floatLiteral value: Double) { + self.init(rawValue: value) + } +} + +extension Percentage: ExpressibleByIntegerLiteral { + public typealias IntegerLiteralType = Int + + public init(integerLiteral value: Int) { + self.init(rawValue: Double(value)) + } +} + +extension Percentage: Numeric { + + public typealias Magnitude = Double.Magnitude + + public init?(exactly source: T) where T : BinaryInteger { + self.init(rawValue: Double(source)) + } + + public var magnitude: Double.Magnitude { + rawValue.magnitude + } + + public static func * (lhs: Percentage, rhs: Percentage) -> Percentage { + .init(rawValue: lhs.rawValue * rhs.rawValue) + } + + public static func + (lhs: Percentage, rhs: Percentage) -> Percentage { + .init(rawValue: lhs.rawValue + rhs.rawValue) + } + + public static func *= (lhs: inout Percentage, rhs: Percentage) { + lhs.rawValue *= rhs.rawValue + } + + public static func - (lhs: Percentage, rhs: Percentage) -> Percentage { + .init(rawValue: lhs.rawValue - rhs.rawValue) + } + +} + +extension Percentage: Comparable { + public static func < (lhs: Percentage, rhs: Percentage) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +extension Percentage: CustomStringConvertible { + + static var formatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + return formatter + }() + + public var description: String { + Self.formatter.string(for: fraction) ?? "\(String(format: "%g", rawValue))%" + } +} + +postfix operator % + +public postfix func %(value: Double) -> Percentage { + .init(rawValue: value) +} + +public postfix func %(value: Int) -> Percentage { + .init(rawValue: Double(value)) +} diff --git a/Sources/SharedModels/HelperTypes/Positive.swift b/Sources/SharedModels/HelperTypes/Positive.swift new file mode 100644 index 0000000..48f8e6a --- /dev/null +++ b/Sources/SharedModels/HelperTypes/Positive.swift @@ -0,0 +1,87 @@ +import Foundation + +@propertyWrapper +public struct Positive where Value: Numeric, Value: Comparable { + + public var wrappedValue: Value + + public init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + } + + public var positiveValue: Value { + guard self.wrappedValue > .zero else { + return wrappedValue * -1 + } + return wrappedValue + } + + public var projectedValue: Self { + get { self } + set { self = newValue } + } +} + +extension Positive: Equatable where Value: Equatable { } + +extension Positive: Comparable where Value: Comparable { + public static func < (lhs: Positive, rhs: Positive) -> Bool { + lhs.wrappedValue < rhs.wrappedValue + } +} + +extension Positive: ExpressibleByIntegerLiteral where Value: ExpressibleByIntegerLiteral { + public typealias IntegerLiteralType = Value.IntegerLiteralType + + public init(integerLiteral value: Value.IntegerLiteralType) { + self.init(wrappedValue: .init(integerLiteral: value)) + } +} + +extension Positive: ExpressibleByFloatLiteral where Value: ExpressibleByFloatLiteral { + public typealias FloatLiteralType = Value.FloatLiteralType + + public init(floatLiteral value: Value.FloatLiteralType) { + self.init(wrappedValue: .init(floatLiteral: value)) + } +} + +extension Positive: AdditiveArithmetic where Value: AdditiveArithmetic { + + public static func - (lhs: Positive, rhs: Positive) -> Positive { + .init(wrappedValue: lhs.wrappedValue - rhs.wrappedValue) + } + + public static func + (lhs: Positive, rhs: Positive) -> Positive { + .init(wrappedValue: lhs.wrappedValue + rhs.wrappedValue) + } + + public static var zero: Positive { + .init(wrappedValue: Value.zero) + } +} + +extension Positive: Numeric where Value: Numeric { + public static func *= (lhs: inout Positive, rhs: Positive) { + lhs.wrappedValue *= rhs.wrappedValue + } + + public init?(exactly source: T) where T : BinaryInteger { + guard let value = Value(exactly: source) else { + return nil + } + self = .init(wrappedValue: value) + } + + public var magnitude: Value.Magnitude { + wrappedValue.magnitude + } + + public static func * (lhs: Positive, rhs: Positive) -> Positive { + .init(wrappedValue: lhs.wrappedValue * rhs.wrappedValue) + } + + public typealias Magnitude = Value.Magnitude + +} + diff --git a/Sources/SharedModels/PlenumDimension.swift b/Sources/SharedModels/PlenumDimension.swift new file mode 100644 index 0000000..46ae542 --- /dev/null +++ b/Sources/SharedModels/PlenumDimension.swift @@ -0,0 +1,33 @@ +import Foundation + +public enum PlenumDimension: Equatable { + case rectangular(width: Double, height: Double) + case round(Double) + + public var areaInSquareFeet: Double { + switch self { + + case .rectangular(width: let width, height: let height): + return (width * height) / 144 + + case let .round(dimension): + return pow((dimension / 12) / 2, 2) * Double.pi + } + } + + public enum Key: String, Equatable, CaseIterable, CustomStringConvertible { + + case rectangular, round + + public var description: String { rawValue.capitalized } + } + + public var key: Key { + switch self { + case .rectangular: + return .rectangular + case .round: + return .round + } + } +} diff --git a/Sources/SharedModels/RatedEnvelope.swift b/Sources/SharedModels/RatedEnvelope.swift new file mode 100644 index 0000000..3e38dd1 --- /dev/null +++ b/Sources/SharedModels/RatedEnvelope.swift @@ -0,0 +1,50 @@ +import Foundation + +public struct RatedEnvelope: Equatable { + public var maximum: Double + public var minimum: Double + public var rated: Double + + public init( + maximum: Double, + minimum: Double, + rated: Double + ) { + self.maximum = maximum + self.minimum = minimum + self.rated = rated + } +} + +// MARK: - Namespaces +public enum StaticPressure { } +public enum AirflowPerTon { } +public enum AirflowLimits { } + +// MARK: - Static Pressures +public typealias RatedStaticPressures = RatedEnvelope +extension RatedStaticPressures { + public init() { + self.init(maximum: 0.8, minimum: 0.3, rated: 0.5) + } +} + +// MARK: - Airflow Per Ton +public typealias RatedAirflowPerTon = RatedEnvelope +extension RatedAirflowPerTon { + public init() { + self.init(maximum: 500, minimum: 350, rated: 400) + } +} + +// MARK: - Airflow Limits +public typealias RatedAirflowLimits = RatedEnvelope +extension RatedAirflowLimits { + public init(tons: CoolingCapacity, using airflowPerTon: RatedAirflowPerTon = .init()) { + self.init( + maximum: airflowPerTon.maximum * tons.rawValue, + minimum: airflowPerTon.minimum * tons.rawValue, + rated: airflowPerTon.rated * tons.rawValue + ) + } +} diff --git a/Sources/SharedModels/Velocity.swift b/Sources/SharedModels/Velocity.swift new file mode 100644 index 0000000..0d50ada --- /dev/null +++ b/Sources/SharedModels/Velocity.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct Velocity: Equatable { + public var airflow: Double + public var dimension: PlenumDimension + + public var value: Double { + airflow / dimension.areaInSquareFeet + } + + public var flagged: Flagged { .velocity(self) } +} + +extension Flagged { + public static func velocity(_ velocity: Velocity) -> Self { + .init(wrappedValue: velocity.value, .velocity()) + } +} diff --git a/Tests/EstimatedPressureTests/EstimatedPressureTests.swift b/Tests/EstimatedPressureTests/EstimatedPressureTests.swift new file mode 100644 index 0000000..9c76c9b --- /dev/null +++ b/Tests/EstimatedPressureTests/EstimatedPressureTests.swift @@ -0,0 +1,94 @@ +import Dependencies +import EstimatedPressureDependency +import SharedModels +import XCTest + +final class PositiveNumericTests: XCTestCase { + + override func invokeTest() { + withDependencies { + $0.estimatedPressuresClient = .liveValue + } operation: { + super.invokeTest() + } + } + + func testEstimatedAirflow() async throws { + @Dependency(\.estimatedPressuresClient.estimatedAirflow) var estimatedAirflow + + let sut = try await estimatedAirflow(.init( + existingAirflow: 1501, + existingPressure: 0.874, + targetPressure: 0.5 + )) + XCTAssertNotNil(sut) + XCTAssertEqual(round(sut.positiveValue), 1135) + } + + func testEstimatedPressure() async throws { + + @Dependency(\.estimatedPressuresClient.estimatedPressure) var estimatedPressure + + let sut = try await estimatedPressure(.init( + existingPressure: 0.3, + existingAirflow: 1084, + targetAirflow: 1050 + )) + XCTAssertEqual(round(sut.positiveValue * 1000) / 1000, 0.281) + } + + func testExternalStaticPressure() { + var envelope = EquipmentMeasurement.furnaceAndCoil( + .init( + returnPlenumPressure: -0.1, + postFilterPressure: -0.2, // here to test it makes positive. + preCoilPressure: 0.4, + supplyPlenumPressure: 0.1 + ) + ) + + var sut = envelope.externalStaticPressure + XCTAssertEqual(round(sut * 10) / 10, 0.6) + + + envelope = .airHandler( + .init( + returnPlenumPressure: -0.2, + postFilterPressure: 0.3, + postCoilPressure: 0.5, + supplyPlenumPressure: 0.3) + ) + + sut = envelope.externalStaticPressure + XCTAssertEqual(round(sut * 10) / 10, 0.5) + + } + + func testExternalStaticPressureReturnsZero() { + XCTAssertEqual( + EquipmentMeasurement.airHandler(.init()).externalStaticPressure, + 0 + ) + XCTAssertEqual( + EquipmentMeasurement.furnaceAndCoil(.init()).externalStaticPressure, + 0 + ) + } + + func testValuesArePositiveOnly() { + let sut = Positive(wrappedValue: -0.1) + XCTAssertEqual(sut.positiveValue, 0.1) + + let sut2 = Positive(wrappedValue: -0.1) + XCTAssertEqual(sut2.positiveValue, 0.1) + } + + func testReturnPlenumDimensionsAreaCalculation() { + var sut = PlenumDimension.rectangular(width: 24, height: 12) + XCTAssertEqual(sut.areaInSquareFeet, 2) + + sut = .round(16) + XCTAssertEqual(round(sut.areaInSquareFeet * 100) / 100, 1.4) + + } +} diff --git a/Tests/EstimatedPressureTests/PercentageTests.swift b/Tests/EstimatedPressureTests/PercentageTests.swift new file mode 100644 index 0000000..6a7aebd --- /dev/null +++ b/Tests/EstimatedPressureTests/PercentageTests.swift @@ -0,0 +1,62 @@ +import SharedModels +import XCTest + +final class PercentageTests: XCTestCase { + + func testPostfixOperator() { + var sut = 50% + XCTAssertEqual(sut.rawValue, 50) + + sut = 50.01% + XCTAssertEqual(sut.rawValue, 50.01) + } + + func testFractionInitialization() { + let sut = Percentage(fraction: 0.5) + XCTAssertEqual(sut.rawValue, 50) + XCTAssertEqual(sut.fraction, 0.5) + } + + func testExpressibleByFloatLiteral() { + let sut: Percentage = 50.01 + XCTAssertEqual(sut.rawValue, 50.01) + } + + func testExpressibleByIntegerLiteral() { + let sut: Percentage = 50 + XCTAssertEqual(sut.rawValue, 50) + } + + func testMultiplication() { + var sut = 2% * 25% + XCTAssertEqual(sut, 50%) + + sut *= 2 + XCTAssertEqual(sut, 100%) + } + + func testAddition() { + var sut = 35% + 15% + XCTAssertEqual(sut, 50%) + + sut += 25% + XCTAssertEqual(sut, 75%) + } + + func testSubtraction() { + let sut = 50% - 15% + XCTAssertEqual(sut, 35%) + } + + func testComparable() { + let sut1 = 50% + let sut2 = 35% + + XCTAssert(sut2 < sut1) + } + + func testCustomStringConvertible() { + let sut = 50% + XCTAssertEqual(sut.description, "50%") + } +}