feat: Working on dehumidifier sizing, api and routes implemented, views are not complete.

This commit is contained in:
2025-02-27 17:10:25 -05:00
parent fad00520b0
commit dc01477c3e
12 changed files with 344 additions and 14 deletions

File diff suppressed because one or more lines are too long

View File

@@ -26,8 +26,11 @@ extension ApiController: DependencyKey {
return .init(json: { route, logger in
switch route {
case let .calculateDehumidifierSize(request):
logger.debug("Calculating dehumidifier size: \(request)")
return try await request.respond(logger)
case let .calculateMoldRisk(request):
logger.info("Calculating mold risk: \(request)")
logger.debug("Calculating mold risk: \(request)")
return try await psychrometricClient.respond(request, logger)
}
})

View File

@@ -0,0 +1,116 @@
import Logging
import Routes
public extension DehumidifierSize.Request {
private static let minTempEfficiency = 65.0
private static let minRHEfficiency = 60.0
private static let dehumidifierSizes = [
33: "https://www.santa-fe-products.com/product/ultramd33-dehumidifier/",
70: "https://www.santa-fe-products.com/product/santa-fe-ultra70-dehumidifier/",
100: "https://www.santa-fe-products.com/product/ultra98-dehumidifier/",
118: "https://www.santa-fe-products.com/product/ultra120-dehumidifier/",
155: "https://www.santa-fe-products.com/product/ultra1559dehumidifier/",
205: "https://www.santa-fe-products.com/product/ultra205-dehumidifier/"
]
func respond(_ logger: Logger) async throws -> DehumidifierSize.Response {
try validate()
let pintsPerDay = (latentLoad / 1054) * 24
var capacityFactor = 1.0
var warnings: [String] = []
if temperature < Self.minTempEfficiency {
if temperature < 60 {
capacityFactor *= 0.5
warnings.append(
"Very low temperature will severely reduce dehumidifier efficiency."
)
} else {
capacityFactor *= 0.7
warnings.append(
"Low temperature will severely reduce dehumidifier efficiency."
)
}
}
if humidity < Self.minRHEfficiency {
capacityFactor *= 0.8
warnings.append(
"Low relative humidity will significantly reduce moisture removal rate."
)
}
let requiredCapacity = pintsPerDay / capacityFactor
addDefaultWarnings(&warnings)
// TODO: Return early here ??
if requiredCapacity > 205 {
logger.debug("Required capacity exceeds residential unit.")
warnings.append(
"Required capacity exceeds largest standard residential unit - consider multiple units or a commercial system"
)
}
let (recommendedSize, recommendedUrl) = parseRecommendedSize(requiredCapacity)
logger.debug("Recommend size: \(recommendedSize)")
return .init(
requiredCapacity: requiredCapacity,
pintsPerDay: pintsPerDay,
recommendedSize: recommendedSize,
recommendedUrl: recommendedUrl,
warnings: warnings
)
}
private func validate() throws {
guard latentLoad > 0 else {
throw DehumidiferSizeValidationError.latentLoadShouldBeGreaterThanZero
}
guard temperature > 0 else {
throw DehumidiferSizeValidationError.temperatureShouldBeGreaterThanZero
}
guard humidity > 0 else {
throw DehumidiferSizeValidationError.humidityShouldBeGreaterThanZero
}
}
private func parseRecommendedSize(_ requiredCapacity: Double) -> (Int, String) {
for (key, value) in Self.dehumidifierSizes where Double(key) >= requiredCapacity {
return (key, value)
}
return (205, Self.dehumidifierSizes[205]!)
}
private func addDefaultWarnings(_ warnings: inout [String]) {
// High humidity warnings
if humidity > 65 {
warnings.append(
"High relative humidity - unit may need to run continuously."
)
}
if humidity > 80 {
warnings.append(
"Extreme humidity levels - address moisture sources and improve ventilation before dehumidification."
)
}
// Temperature warnings
if temperature < 60 {
warnings.append(
"Temperature too low for effective dehumidification - consider raising space temperature first."
)
}
}
}
enum DehumidiferSizeValidationError: Error {
case latentLoadShouldBeGreaterThanZero
case temperatureShouldBeGreaterThanZero
case humidityShouldBeGreaterThanZero
}

View File

@@ -0,0 +1,44 @@
import Foundation
public enum DehumidifierSize {
/// Represents the request for determining dehumidifier size based on
/// latent load and indoor conditions.
public struct Request: Codable, Equatable, Sendable {
public let latentLoad: Double
public let temperature: Double
public let humidity: Double
public init(latentLoad: Double, temperature: Double, humidity: Double) {
self.latentLoad = latentLoad
self.temperature = temperature
self.humidity = humidity
}
}
/// Represents the response for determining dehumidifier size based on
/// latent load and indoor conditions.
public struct Response: Codable, Equatable, Sendable {
public let requiredCapacity: Double
public let pintsPerDay: Double
public let recommendedSize: Int
public let recommendedUrl: String?
public let warnings: [String]
public init(
requiredCapacity: Double,
pintsPerDay: Double,
recommendedSize: Int,
recommendedUrl: String? = nil,
warnings: [String] = []
) {
self.requiredCapacity = requiredCapacity
self.pintsPerDay = pintsPerDay
self.recommendedSize = recommendedSize
self.recommendedUrl = recommendedUrl
self.warnings = warnings
}
}
}

View File

@@ -22,11 +22,18 @@ public enum SiteRoute: Equatable, Sendable {
public extension SiteRoute {
enum Api: Equatable, Sendable {
case calculateDehumidifierSize(DehumidifierSize.Request)
case calculateMoldRisk(MoldRisk.Request)
static let rootPath = Path { "api"; "v1" }
public static let router = OneOf {
Route(.case(Self.calculateDehumidifierSize)) {
Path { "api"; "v1"; "calculateDehumidifierSize" }
Method.post
Body(.json(DehumidifierSize.Request.self))
}
Route(.case(Self.calculateMoldRisk)) {
Path { "api"; "v1"; "calculateMoldRisk" }
Method.post
@@ -40,17 +47,47 @@ public extension SiteRoute {
enum View: Equatable, Sendable {
case index
case dehumidifierSize(DehumidifierSize)
case moldRisk(MoldRisk)
public static let router = OneOf {
Route(.case(Self.index)) {
Method.get
}
Route(.case(Self.dehumidifierSize)) {
DehumidifierSize.router
}
Route(.case(Self.moldRisk)) {
MoldRisk.router
}
}
public enum DehumidifierSize: Equatable, Sendable {
case index
case submit(Routes.DehumidifierSize.Request)
static let rootPath = "dehumidifier-size"
public static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
Route(.case(Self.submit)) {
Path { rootPath }
Method.post
Body {
FormData {
Field("latentLoad") { Double.parser() }
Field("temperature") { Double.parser() }
Field("humidity") { Double.parser() }
}
.map(.memberwise(Routes.DehumidifierSize.Request.init))
}
}
}
}
public enum MoldRisk: Equatable, Sendable {
case index
case submit(Routes.MoldRisk.Request)

View File

@@ -0,0 +1,20 @@
import Elementary
public struct Note: HTML, Sendable {
let label: String
let text: String
public init(_ label: String = "Note:", _ text: () -> String) {
self.label = label
self.text = text()
}
public var content: some HTML {
div(.class("mt-8 p-4 bg-gray-100 dark:bg-gray-700 rounded-md shadow-md border border-blue-500")) {
p(.class("text-sm text-blue-500")) {
span(.class("font-extrabold pe-2")) { label }
text
}
}
}
}

View File

@@ -46,6 +46,7 @@ extension ViewController: DependencyKey {
@Dependency(\.psychrometricClient) var psychrometricClient
return .init(view: { request in
request.logger.debug("View route: \(request.route)")
switch request.route {
case .index:
return MainPage {
@@ -67,6 +68,16 @@ extension ViewController: DependencyKey {
}
}
}
case let .dehumidifierSize(route):
switch route {
case .index:
return request.respond(DehumidifierSizeForm())
case let .submit(sizeRequest):
let response = try await sizeRequest.respond(request.logger)
return DehumidifierSizeResult(response: response)
}
case let .moldRisk(route):
switch route {
case .index:

View File

@@ -0,0 +1,96 @@
import Elementary
import ElementaryHTMX
import Routes
import Styleguide
struct DehumidifierSizeForm: HTML {
var content: some HTML {
FormHeader(label: "Dehumidifier Sizing Calculator", svg: .droplets)
form(
// Using index to get the correct path, but really uses the `submit` end-point.
.hx.post(route: .dehumidifierSize(.index)),
.hx.target("#result")
) {
div(.class("space-y-6")) {
LabeledContent(label: "Latent Load (BTU/h)") {
Input(id: "latentLoad", placeholder: "Latent load from Manual-J")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .autofocus, .required)
}
LabeledContent(label: "Indoor Temperature (°F)") {
Input(id: "temperature", placeholder: "Indoor dry bulb temperature")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .required)
}
LabeledContent(label: "Indoor Humdity (%)") {
Input(id: "humidity", placeholder: "Relative humidity")
.attributes(.type(.number), .step("0.1"), .min("0.1"), .required)
}
div {
SubmitButton(label: "Calculate Dehumidifier Size")
}
}
}
div(.id("result")) {}
}
}
struct DehumidifierSizeResult: HTML {
let response: DehumidifierSize.Response
var content: some HTML {
ResultContainer(reset: .dehumidifierSize(.index)) {
div(.class("""
p-2 rounded-lg shadow-lg bg-blue-500
"""
)) {
p { "Recommended Size: \(response.recommendedSize)" }
}
// Display warnings, if applicable
if response.warnings.count > 0 {
div(.class("w-full mt-8 p-4 rounded-lg shadow-lg text-amber-500 bg-amber-200 border border-amber-500")) {
span(.class("font-semibold mb-4 border-b")) { "Warning\(response.warnings.count > 1 ? "s:" : ":")" }
ul(.class("list-disc mx-10")) {
for warning in response.warnings {
li { warning }
}
}
}
}
Note {
"""
Sizing is based on continuous operation at rated conditions. Actual performance will vary based on
operating conditions. Consider Energy Star rated units for better efficiency. For whole-house
applications, ensure proper air distribution and drainage.
"""
}
}
}
}
// <div className = "p-4 bg-white rounded-lg shadow-sm">
// <p className = Recommended < "text-sm text-gray-600 mb-2" Size: </p>
// <a
// href = { result.recommendedSize.url }
// target = "_blank"
// rel = "noopener noreferrer"
// className = "block p-4 bg-blue-100 rounded-lg hover:bg-blue-200 transition-colors group"
// >
// <div className = "flex items-center justify-between">
// <div>
// <span className = "text-2xl font-bold text-blue-700">
// { result.recommendedSize.size } PPD
// </span>
// <p className = "text-sm text-blue-600 mt-1">
// Click to view available models
// </p>
// </div>
// <div className = "w-12 h-12 bg-blue-200 rounded-full flex items-center justify-center group-hover:bg-blue-300 transition-colors">
// <Droplets className = "w-6 h-6 text-blue-700" />
// </div>
// </div>
// </a>
// </div >

View File

@@ -1,4 +1,6 @@
import Elementary
import ElementaryHTMX
import Routes
import Styleguide
struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable {
@@ -68,7 +70,10 @@ private struct Header: HTML {
}
}
li {
a(.href("#"), .class("[&:hover]:border-b \(border: .yellow)")) {
a(
.class("[&:hover]:border-b \(border: .yellow)"),
.hx.get(route: .dehumidifierSize(.index)), .hx.target("#content"), .hx.pushURL(true)
) {
"Dehumidifier-Sizing"
}
}

View File

@@ -1,4 +1,5 @@
import Elementary
import ElementaryHTMX
import PsychrometricClient
import Routes
import Styleguide
@@ -20,7 +21,7 @@ struct MoldRiskForm: HTML {
.attributes(.type(.number), .step("0.1"), .min("0.1"), .autofocus)
}
LabeledContent(label: "Indor Humdity (%)") {
LabeledContent(label: "Indoor Humdity (%)") {
Input(id: "humidity", placeholder: "Relative humidity")
.attributes(.type(.number), .step("0.1"), .min("0.1"))
}
@@ -88,15 +89,12 @@ struct MoldRiskResponse: HTML {
.attributes(.class("mx-6"))
// Disclaimer.
div(.class("mt-8 p-4 bg-gray-100 dark:bg-gray-700 rounded-md shadow-md border border-blue-500")) {
p(.class("text-sm text-blue-500")) {
span(.class("font-extrabold pe-2")) { "Note:" }
"""
These calculations are based on typical indoor conditions and common mold species. Actual mold growth can
vary based on surface materials, air movement, and other environmental factors. Always address moisture
issues promptly and consult professionals for severe cases.
"""
}
Note {
"""
These calculations are based on typical indoor conditions and common mold species. Actual mold growth can
vary based on surface materials, air movement, and other environmental factors. Always address moisture
issues promptly and consult professionals for severe cases.
"""
}
}
}

View File

@@ -4,7 +4,7 @@ run:
touch .build/browser-dev-sync
browser-sync start -p localhost:8080 --ws &
watchexec -w Sources -e .swift -r 'swift build --product App && touch .build/browser-dev-sync' &
watchexec -w .build/browser-dev-sync --ignore-nothing -r '.build/debug/App'
watchexec -w .build/browser-dev-sync --ignore-nothing -r '.build/debug/App serve --log debug'
run-css:
@pnpm run css-watch