feat: Adds capacitor calculations.

This commit is contained in:
2025-03-01 18:11:49 -05:00
parent eaf0387899
commit 3c7147ad0e
11 changed files with 622 additions and 39 deletions

File diff suppressed because one or more lines are too long

View File

@@ -28,6 +28,10 @@ extension ApiController: DependencyKey {
logger.debug("API Route: \(route)")
switch route {
case let .calculateCapacitor(request):
logger.debug("Calculating capacitor: \(request)")
return try await request.respond(logger: logger)
case let .calculateDehumidifierSize(request):
logger.debug("Calculating dehumidifier size: \(request)")
return try await request.respond(logger)
@@ -42,8 +46,7 @@ extension ApiController: DependencyKey {
case let .calculateRoomPressure(request):
logger.debug("Calculating room pressure: \(request)")
// FIX:
fatalError()
return try await request.respond(logger: logger)
}
})
}

View File

@@ -0,0 +1,117 @@
import Foundation
import Logging
import OrderedCollections
import Routes
public extension Capacitor.Request {
static let standardCapacitorSizes = OrderedSet([
5, 7.5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 70, 80
])
func respond(logger: Logger) async throws -> Capacitor.Response {
switch self {
case let .size(request):
return try calculateSize(request, logger)
case let .test(request):
return try calculateCapcitance(request, logger)
}
}
private func calculateCapcitance(
_ request: Capacitor.Request.TestRequest,
_ logger: Logger
) throws -> Capacitor.Response {
try request.validate()
let capacitance = (2653 * request.startWindingAmps) / request.runToCommonVoltage
logger.debug("Test Capacitor calculated capacitance: \(capacitance)")
var ratedComparison: Capacitor.RatedComparison?
if let rating = request.ratedCapacitorSize {
let rating = Double(rating)
let deviation = ((capacitance - rating) / rating) * 100.0
logger.debug("Test Capacitor calculated deviation: \(deviation)")
// let positiveDeviation = deviation.ensurePostive()
ratedComparison = .init(
value: Int(rating),
isInRange: abs(deviation) <= 6,
percentDeviation: deviation
)
}
return .test(result: .init(
capacitance: capacitance,
tolerance: .init(capacitance: capacitance),
ratedComparison: ratedComparison
))
}
private func calculateSize(
_ request: Capacitor.Request.SizeRequest,
_ logger: Logger
) throws -> Capacitor.Response {
try request.validate()
let frequency = 60.0
let phaseAngle = acos(request.powerFactor)
let reactiveComponent = request.runningAmps * sin(phaseAngle)
let capacitance = (reactiveComponent * 1_000_000) / (2 * Double.pi * frequency * request.lineVoltage)
logger.debug("Calculate capacitor size capacitance: \(capacitance)")
let standardSize = Self.standardCapacitorSizes.first(where: { $0 >= capacitance })
?? Self.standardCapacitorSizes.last!
logger.debug("Calculate capacitor standard size: \(standardSize)")
return .size(result: .init(
capacitance: capacitance,
standardSize: standardSize,
tolerance: .init(capacitance: capacitance)
))
}
}
private extension Capacitor.Tolerance {
init(capacitance: Double) {
// +- 6% tolerance
self.init(
minimum: capacitance * 0.96,
maximum: capacitance * 1.06
)
}
}
private extension Capacitor.Request.TestRequest {
func validate() throws {
guard startWindingAmps > 0 else {
throw ValidationError(message: "Start winding amps should be greater than 0.")
}
guard runToCommonVoltage > 0 else {
throw ValidationError(message: "Run to common voltage should be greater than 0.")
}
if let ratedCapacitorSize {
guard ratedCapacitorSize > 0 else {
throw ValidationError(message: "Run to common voltage should be greater than 0.")
}
}
}
}
private extension Capacitor.Request.SizeRequest {
func validate() throws {
guard runningAmps > 0 else {
throw ValidationError(message: "Running amps should be greater than 0.")
}
guard lineVoltage > 0 else {
throw ValidationError(message: "Line voltage should be greater than 0.")
}
guard powerFactor > 0, powerFactor < 1.01 else {
throw ValidationError(message: "powerFactor should be greater than 0 and at max 1.")
}
}
}

View File

@@ -0,0 +1,9 @@
import Foundation
extension Double {
func ensurePostive() -> Self {
if self < 0 { return self * -1 }
return self
}
}

View File

@@ -0,0 +1,121 @@
public enum Capacitor {
public enum Mode: String, CaseIterable, Codable, Equatable, Sendable {
case size
case test
}
public enum Request: Codable, Equatable, Sendable {
case size(SizeRequest)
case test(TestRequest)
public struct SizeRequest: Codable, Equatable, Sendable {
public let runningAmps: Double
public let lineVoltage: Double
public let powerFactor: Double
public init(runningAmps: Double, lineVoltage: Double, powerFactor: Double) {
self.runningAmps = runningAmps
self.lineVoltage = lineVoltage
self.powerFactor = powerFactor
}
}
public struct TestRequest: Codable, Equatable, Sendable {
public let startWindingAmps: Double
public let runToCommonVoltage: Double
public let ratedCapacitorSize: Int?
public init(startWindingAmps: Double, runToCommonVoltage: Double, ratedCapacitorSize: Int? = nil) {
self.startWindingAmps = startWindingAmps
self.runToCommonVoltage = runToCommonVoltage
self.ratedCapacitorSize = ratedCapacitorSize
}
}
}
public enum Response: Codable, Equatable, Sendable {
case size(result: SizeResponse)
case test(result: TestResponse)
public struct SizeResponse: Codable, Equatable, Sendable {
public let capacitance: Double
public let standardSize: Double
public let tolerance: Tolerance
public init(capacitance: Double, standardSize: Double, tolerance: Capacitor.Tolerance) {
self.capacitance = capacitance
self.standardSize = standardSize
self.tolerance = tolerance
}
}
public struct TestResponse: Codable, Equatable, Sendable {
public let capacitance: Double
public let tolerance: Tolerance
public let ratedComparison: RatedComparison?
public init(
capacitance: Double,
tolerance: Capacitor.Tolerance,
ratedComparison: Capacitor.RatedComparison? = nil
) {
self.capacitance = capacitance
self.tolerance = tolerance
self.ratedComparison = ratedComparison
}
}
}
public struct Tolerance: Codable, Equatable, Sendable {
public let minimum: Double
public let maximum: Double
public init(minimum: Double, maximum: Double) {
self.minimum = minimum
self.maximum = maximum
}
}
public struct RatedComparison: Codable, Equatable, Sendable {
public let value: Int
public let isInRange: Bool
public let percentDeviation: Double
public init(value: Int, isInRange: Bool, percentDeviation: Double) {
self.value = value
self.isInRange = isInRange
self.percentDeviation = percentDeviation
}
}
}
#if DEBUG
public extension Capacitor.Response {
static func mock(mode: Capacitor.Mode) -> Self {
switch mode {
case .size:
return .size(result: .init(
capacitance: 57.5,
standardSize: 60,
tolerance: .init(minimum: 54.1, maximum: 61)
))
case .test:
return .test(result: .init(
capacitance: 34.8,
tolerance: .init(minimum: 32.7, maximum: 36.9),
ratedComparison: .init(value: 35, isInRange: Bool.random(), percentDeviation: 0.6)
))
}
}
}
#endif

View File

@@ -23,6 +23,7 @@ public extension SiteRoute {
enum Api: Equatable, Sendable {
case calculateCapacitor(Capacitor.Request)
case calculateDehumidifierSize(DehumidifierSize.Request)
case calculateHVACSystemPerformance(HVACSystemPerformance.Request)
case calculateMoldRisk(MoldRisk.Request)
@@ -31,6 +32,16 @@ public extension SiteRoute {
static let rootPath = Path { "api"; "v1" }
public static let router = OneOf {
Route(.case(Self.calculateCapacitor)) {
Path { "api"; "v1"; "calculateRoomPressure" }
Method.post
OneOf {
Body(.json(Capacitor.Request.SizeRequest.self))
.map(.case(Capacitor.Request.size))
Body(.json(Capacitor.Request.TestRequest.self))
.map(.case(Capacitor.Request.test))
}
}
Route(.case(Self.calculateDehumidifierSize)) {
Path { "api"; "v1"; "calculateDehumidifierSize" }
Method.post
@@ -64,6 +75,7 @@ public extension SiteRoute {
enum View: Equatable, Sendable {
case index
case capacitor(Capacitor)
case dehumidifierSize(DehumidifierSize)
case hvacSystemPerformance(HVACSystemPerformance)
case moldRisk(MoldRisk)
@@ -73,6 +85,9 @@ public extension SiteRoute {
Route(.case(Self.index)) {
Method.get
}
Route(.case(Self.capacitor)) {
Capacitor.router
}
Route(.case(Self.dehumidifierSize)) {
DehumidifierSize.router
}
@@ -87,6 +102,50 @@ public extension SiteRoute {
}
}
public enum Capacitor: Equatable, Sendable {
case index(mode: Routes.Capacitor.Mode? = nil)
case submit(Routes.Capacitor.Request)
public static var index: Self { .index() }
static let rootPath = "capacitor-calculator"
public static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
Query {
Optionally { Field("mode") { Routes.Capacitor.Mode.parser() } }
}
}
Route(.case(Self.submit)) {
Path { rootPath }
Method.post
Body {
OneOf {
FormData {
Field("runningAmps") { Double.parser() }
Field("lineVoltage") { Double.parser() }
Field("powerFactor") { Double.parser() }
}
.map(.memberwise(Routes.Capacitor.Request.SizeRequest.init))
.map(.case(Routes.Capacitor.Request.size))
FormData {
Field("startWindingAmps") { Double.parser() }
Field("runToCommonVoltage") { Double.parser() }
Optionally {
Field("ratedCapacitorSize") { Int.parser() }
}
}
.map(.memberwise(Routes.Capacitor.Request.TestRequest.init))
.map(.case(Routes.Capacitor.Request.test))
}
}
}
}
}
public enum DehumidifierSize: Equatable, Sendable {
case index
case submit(Routes.DehumidifierSize.Request)

View File

@@ -44,6 +44,7 @@ public struct SVGSize: Sendable {
public enum SVGType: Sendable, CaseIterable {
case calculator
case checkCircle
case droplets
case exclamation
case funnel
@@ -60,6 +61,7 @@ public enum SVGType: Sendable, CaseIterable {
public func html(_ size: SVGSize) -> some HTML {
switch self {
case .calculator: return calculatorSvg(size: size)
case .checkCircle: return checkCircleSvg(size: size)
case .droplets: return dropletsSvg(size: size)
case .exclamation: return exclamationSvg(size: size)
case .funnel: return funnelSvg(size: size)
@@ -80,6 +82,14 @@ public enum SVGType: Sendable, CaseIterable {
// swiftlint:disable line_length
private func checkCircleSvg(size: SVGSize) -> HTMLRaw {
HTMLRaw("""
<svg xmlns="http://www.w3.org/2000/svg" width="\(size.width)" height="\(size.height)" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-check-big">
<path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/>
</svg>
""")
}
private func houseSvg(size: SVGSize) -> HTMLRaw {
HTMLRaw("""
<svg xmlns="http://www.w3.org/2000/svg" width="\(size.width)" height="\(size.height)" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-house">

View File

@@ -76,6 +76,17 @@ extension ViewController: DependencyKey {
}
}
case let .capacitor(route):
switch route {
case let .index(mode: mode):
logger.debug("Capacitor view: \(String(describing: mode))")
return request.respond(CapacitorForm(mode: mode, response: nil))
case let .submit(request):
logger.debug("Capaitor view submit: \(request)")
let response = try await request.respond(logger: logger)
return CapacitorResponse(response: response)
}
case let .dehumidifierSize(route):
switch route {
case .index:

View File

@@ -66,6 +66,7 @@ private struct Header: HTML {
navLink(label: "Dehumidifier-Sizing", route: .dehumidifierSize(.index))
navLink(label: "HVAC-System-Performance", route: .hvacSystemPerformance(.index))
navLink(label: "Room-Pressure", route: .roomPressure(.index))
navLink(label: "Capcitor-Calculator", route: .capacitor(.index))
}
}
}

View File

@@ -0,0 +1,239 @@
import Elementary
import ElementaryHTMX
import Routes
import Styleguide
struct CapacitorForm: HTML, Sendable {
let mode: Capacitor.Mode
let response: Capacitor.Response?
init(mode: Capacitor.Mode?, response: Capacitor.Response? = nil) {
self.mode = mode ?? .test
self.response = response
}
var content: some HTML {
div(.class("relative")) {
div(.class("flex flex-wrap justify-between")) {
FormHeader(label: "Capacitor Calculator - \(mode.rawValue.capitalized) Capacitor", svg: .zap)
// Mode toggle / buttons.
div(.class("flex items-center gap-x-0")) {
switch mode {
case .test:
SecondaryButton(label: "Test Capacitor")
.attributes(.class("rounded-s-lg"))
.attributes(.disabled, when: mode == .test)
.attributes(
.hx.get(route: .capacitor(.index(mode: .test))),
.hx.target("#content"),
when: mode == .size
)
PrimaryButton(label: "Size Capacitor")
.attributes(.class("rounded-e-lg"))
.attributes(
.hx.get(route: .capacitor(.index(mode: .size))),
.hx.target("#content"),
when: mode == .test
)
case .size:
PrimaryButton(label: "Test Capacitor")
.attributes(.class("rounded-s-lg"))
.attributes(
.hx.get(route: .capacitor(.index(mode: .test))),
.hx.target("#content"),
when: mode == .size
)
SecondaryButton(label: "Size Capacitor")
.attributes(.class("rounded-e-lg"))
.attributes(.disabled, when: mode == .size)
.attributes(
.hx.get(route: .capacitor(.index(mode: .size))),
.hx.target("#content"),
when: mode == .test
)
}
}
}
Form(mode: mode).attributes(.class("mt-6"))
div(.id("result")) {
if let response {
CapacitorResponse(response: response)
}
}
}
}
private struct Form: HTML, Sendable {
let mode: Capacitor.Mode
var content: some HTML<HTMLTag.form> {
form(
.hx.post(route: .capacitor(.index)),
.hx.target("#result")
) {
div(.class("space-y-6")) {
switch mode {
case .size:
LabeledContent(label: "Running Current: (amps)") {
Input(id: "runningAmps", placeholder: "Current amps")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .autofocus, .required)
}
LabeledContent(label: "Line Voltage") {
Input(id: "lineVoltage", placeholder: "Voltage")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .required)
}
LabeledContent(label: "Power Factor") {
Input(id: "powerFactor", placeholder: "Power factor (0-1)")
.attributes(.type(.number), .step("0.01"), .min("0.1"), .max("1.00"), .required)
}
case .test:
LabeledContent(label: "Start Winding: (amps)") {
Input(id: "startWindingAmps", placeholder: "Current amps")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .autofocus, .required)
}
LabeledContent(label: "Run to Common: (volts)") {
Input(id: "runToCommonVoltage", placeholder: "Voltage")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .required)
}
LabeledContent(label: "Capacitor Rated Size: (µF)") {
Input(id: "ratedCapacitorSize", placeholder: "Size (optional)")
.attributes(.type(.number), .step("0.1"), .min("0.1"))
}
}
div {
SubmitButton(label: "\(mode == .size ? "Calculate Size" : "Test Capacitor")")
}
}
}
}
}
}
struct CapacitorResponse: HTML, Sendable {
let response: Capacitor.Response
var content: some HTML {
ResultContainer(reset: .capacitor(.index(mode: response.mode))) {
switch response {
case let .size(result: result):
sizeResponse(result)
case let .test(result: result):
testResponse(result)
}
}
}
private func sizeResponse(_ response: Capacitor.Response.SizeResponse) -> some HTML {
div {
div(
.class("""
w-full rounded-lg shadow-lg p-4
border-2 border-blue-600 bg-blue-100 text-blue-600
""")
) {
div(.class("flex justify-center")) {
span(.class("font-extrabold")) { "Required Capacitor Size" }
}
row("Recommended Standard Size", "\(response.standardSize) µF")
row("Calculated Size:", "\(double: response.capacitance, fractionDigits: 1) µF")
toleranceRow(response.tolerance)
}
WarningBox(
"Always verify voltage rating matches the application.",
"Use the next larger size if exact match is unavailable.",
"Ensure capacitor is rated for continuous duty.",
"Consider ambient temperature in final selection."
) { _ in
span(.class("font-extrabold")) { "Important Notes:" }
}
}
}
private func testResponse(_ response: Capacitor.Response.TestResponse) -> some HTML {
div {
if let comparison = response.ratedComparison {
div(
.class("""
w-full rounded-lg shadow-lg p-4
border-2 \(comparison.borderColor) \(comparison.bgColor) \(comparison.textColor)
""")
) {
div(.class("flex font-bold gap-x-4")) {
SVG(
comparison.isInRange ? .checkCircle : .exclamation,
color: comparison.textColor
)
span(.class("font-normal")) {
"Capacitor is \(comparison.isInRange ? "within" : "outside of") acceptable range: (± 6% of rated value)"
}
}
}
}
div(.class("grid grid-cols-1 \(response.ratedComparison != nil ? "lg:grid-cols-2" : "") gap-4 mt-8")) {
div(.class("bg-blue-100 rounded-lg border-2 border-blue-600 text-blue-500 px-4 pb-4")) {
div(.class("flex justify-center mb-6 mt-2")) {
span(.class("text-2xl font-extrabold")) { "Measured" }
}
row("Capacitance", "\(double: response.capacitance) µF")
toleranceRow(response.tolerance)
}
if let comparison = response.ratedComparison {
div(.class("bg-blue-100 rounded-lg border-2 border-blue-600 text-blue-500 px-4 pb-4")) {
div(.class("flex justify-center mb-6 mt-2")) {
span(.class("text-2xl font-extrabold")) { "Rated Comparison" }
}
row("Rated Value", "\(comparison.value) µF")
row("Deviation", "\(double: comparison.percentDeviation, fractionDigits: 1)%")
}
}
}
}
}
private func row(_ label: String, _ value: String) -> some HTML {
div(.class("flex justify-between mb-2")) {
span(.class("font-semibold")) { label }
span { value }
}
}
private func toleranceRow(_ tolerance: Capacitor.Tolerance) -> some HTML {
row(
"Acceptable Range (±6%):",
"\(double: tolerance.minimum, fractionDigits: 1) - \(double: tolerance.maximum, fractionDigits: 1) µF"
)
}
}
private extension Capacitor.RatedComparison {
var bgColor: String {
guard isInRange else { return "bg-red-100" }
return "bg-green-100"
}
var borderColor: String {
guard isInRange else { return "border-red-600" }
return "border-green-600"
}
var textColor: String {
guard isInRange else { return "text-red-600" }
return "text-green-600"
}
}
private extension Capacitor.Response {
var mode: Capacitor.Mode {
switch self {
case .size: return .size
case .test: return .test
}
}
}

View File

@@ -14,10 +14,11 @@ struct RoomPressureForm: HTML, Sendable {
var content: some HTML {
div(.class("relative")) {
FormHeader(label: "Room Pressure Calculator", svg: .leftRightArrow)
div(.class("flex flex-wrap justify-between")) {
FormHeader(label: "Room Pressure Calculator - \(mode.label)", svg: .leftRightArrow)
// Mode toggle / buttons.
div(.class("absolute top-0 right-0 flex items-center gap-x-0")) {
div(.class("flex items-center gap-x-0")) {
switch mode {
case .knownAirflow:
SecondaryButton(label: "Known Airflow")
@@ -53,8 +54,10 @@ struct RoomPressureForm: HTML, Sendable {
)
}
}
}
Form(mode: mode)
.attributes(.class("mt-6"))
div(.id("result")) {
if let response {
@@ -241,3 +244,13 @@ struct RoomPressureResult: HTML, Sendable {
}
}
}
private extension RoomPressure.Mode {
var label: String {
switch self {
case let .knownAirflow: return "Known Airflow"
case let .measuredPressure: return "Measured Pressure"
}
}
}