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,9 +89,7 @@ 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:" }
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
@@ -99,7 +98,6 @@ struct MoldRiskResponse: HTML {
}
}
}
}
}
private extension MoldRisk.RiskLevel {

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