From 38183f52820e9e608c9370eca905735bba50dc86 Mon Sep 17 00:00:00 2001 From: Stefan Lange-Hegermann Date: Tue, 18 Aug 2026 10:59:16 +0200 Subject: [PATCH] Calculator: size cables for ampacity and drop budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recommendedCrossSection now satisfies two criteria and takes whichever demands more copper: the voltage-drop budget, and an ampacity at least as high as the fuse protecting the circuit (ABYC E-11: the OCP rating must not exceed the conductor's ampacity). Ampacity comes from ABYC E-11 Table 6A for AWG and ISO 13297 Table A1 for mm², both 105 °C. Before this, a 40 A load over 0.5 m was sized 1.5 mm² / AWG 16 next to a 50 A fuse: a cable the fuse can never protect. Long runs stay voltage-drop driven and unchanged. The drop budget is now stored per system (3 / 5 / 10 %) and editable in the system editor, with a global default in Settings that the standalone calculator uses and new systems inherit. Cross-sections are derived, never entered by the user, so LoadCableSync re-derives them when a system opens and when its budget changes. Library loads are computed instead of being stored as a 1.0 mm² placeholder. LoadConfigurationStatus therefore reports only what the user can actually fix: missing length or current. Also removes the dead cable.pro.* strings and folds three copies of the mm²-to-AWG conversion into ElectricalCalculations.nearestAWG. --- Cable/Base.lproj/Localizable.strings | 53 +---- Cable/Loads/CableCalculator.swift | 28 ++- Cable/Loads/CalculatorView.swift | 6 +- Cable/Loads/ElectricalCalculations.swift | 92 +++++++- Cable/Loads/LoadCableSync.swift | 32 +++ Cable/Loads/LoadConfigurationStatus.swift | 18 +- Cable/Loads/LoadsView.swift | 44 ++-- Cable/Overview/SystemOverviewView.swift | 11 +- Cable/SettingsView.swift | 22 ++ Cable/Systems/SystemBillOfMaterialsView.swift | 14 +- .../Systems/SystemComponentsPersistence.swift | 30 ++- Cable/Systems/SystemEditorView.swift | 83 ++++++- Cable/Systems/SystemsView.swift | 3 +- Cable/UnitSystem.swift | 11 + Cable/de.lproj/Localizable.strings | 53 +---- Cable/es.lproj/Localizable.strings | 14 +- Cable/fr.lproj/Localizable.strings | 14 +- Cable/nl.lproj/Localizable.strings | 14 +- CableTests/CableTests.swift | 209 +++++++++++++++++- .../SystemVoltageDropTargetUITests.swift | 64 ++++++ 20 files changed, 624 insertions(+), 191 deletions(-) create mode 100644 Cable/Loads/LoadCableSync.swift create mode 100644 CableUITestsScreenshot/SystemVoltageDropTargetUITests.swift diff --git a/Cable/Base.lproj/Localizable.strings b/Cable/Base.lproj/Localizable.strings index 6d36169..21b1e2e 100644 --- a/Cable/Base.lproj/Localizable.strings +++ b/Cable/Base.lproj/Localizable.strings @@ -117,10 +117,6 @@ "bom.quantity.terminal.badge" = "%1$d× · %2$@"; "bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@"; "bom.quantity.single.badge" = "1× • %@"; -"cable.pro.privacy.label" = "Privacy"; -"cable.pro.privacy.url" = "https://voltplan.app/privacy"; -"cable.pro.terms.label" = "Terms"; -"cable.pro.terms.url" = "https://voltplan.app/terms"; "calculator.advanced.duty_cycle.helper" = "Percentage of each active session where the load actually draws power."; "calculator.advanced.duty_cycle.title" = "Duty Cycle"; "calculator.advanced.section.title" = "Advanced Settings"; @@ -199,7 +195,7 @@ "loads.overview.metric.current" = "Total Current"; "loads.overview.metric.power" = "Total Power"; "loads.overview.status.missing_details.banner" = "Finish configuring your loads"; -"loads.overview.status.missing_details.message" = "Enter cable length and wire size for %d %@ to see accurate recommendations."; +"loads.overview.status.missing_details.message" = "Enter cable length and current draw for %d %@ so Cable can size the wiring."; "loads.overview.status.missing_details.plural" = "loads"; "loads.overview.status.missing_details.singular" = "load"; "loads.overview.status.missing_details.title" = "Missing load details"; @@ -289,47 +285,7 @@ "settings.pro.manage.url" = "https://apps.apple.com/account/subscriptions"; "settings.pro.day.one" = "%@ day"; "settings.pro.day.other" = "%@ days"; -"cable.pro.terms.label" = "Terms"; -"cable.pro.privacy.label" = "Privacy"; -"cable.pro.terms.url" = "https://voltplan.app/terms"; -"cable.pro.privacy.url" = "https://voltplan.app/privacy"; -"cable.pro.paywall.title" = "Cable PRO"; -"cable.pro.paywall.subtitle" = "Cable PRO enables more configuration options for loads, batteries and chargers."; -"cable.pro.feature.dutyCycle" = "Duty-cycle aware cable calculators"; -"cable.pro.feature.batteryCapacity" = "Configure usable battery capacity"; -"cable.pro.feature.usageBased" = "Usage based calculations"; -"cable.pro.button.unlock" = "Unlock Now"; -"cable.pro.button.freeTrial" = "Start Free Trial"; -"cable.pro.button.unlocked" = "Unlocked"; -"cable.pro.restore.button" = "Restore Purchases"; -"cable.pro.alert.success.title" = "Cable PRO Unlocked"; -"cable.pro.alert.success.body" = "Thanks for supporting Cable PRO!"; -"cable.pro.alert.pending.title" = "Purchase Pending"; -"cable.pro.alert.pending.body" = "Your purchase is awaiting approval."; -"cable.pro.alert.restored.title" = "Purchases Restored"; -"cable.pro.alert.restored.body" = "Your purchases are available again."; -"cable.pro.alert.error.title" = "Purchase Failed"; -"cable.pro.alert.error.generic" = "Something went wrong. Please try again."; "generic.ok" = "OK"; -"cable.pro.trial.badge" = "Includes a %@ free trial"; -"cable.pro.subscription.renews" = "Renews %@."; -"cable.pro.subscription.trialThenRenews" = "Free trial, then renews %@."; -"cable.pro.duration.day.singular" = "every day"; -"cable.pro.duration.day.plural" = "every %@ days"; -"cable.pro.duration.week.singular" = "every week"; -"cable.pro.duration.week.plural" = "every %@ weeks"; -"cable.pro.duration.month.singular" = "every month"; -"cable.pro.duration.month.plural" = "every %@ months"; -"cable.pro.duration.year.singular" = "every year"; -"cable.pro.duration.year.plural" = "every %@ years"; -"cable.pro.trial.duration.day.singular" = "%@-day"; -"cable.pro.trial.duration.day.plural" = "%@-day"; -"cable.pro.trial.duration.week.singular" = "%@-week"; -"cable.pro.trial.duration.week.plural" = "%@-week"; -"cable.pro.trial.duration.month.singular" = "%@-month"; -"cable.pro.trial.duration.month.plural" = "%@-month"; -"cable.pro.trial.duration.year.singular" = "%@-year"; -"cable.pro.trial.duration.year.plural" = "%@-year"; // MARK: - PDF Overview Export "overview.pdf.loads.section" = "Loads"; @@ -388,3 +344,10 @@ "overview.share.diagram" = "Wiring Diagram"; "overview.share.pdf" = "Full Report (PDF)"; "overview.share.diagram.error" = "Could not generate diagram. Check your internet connection."; + +"editor.system.voltage_drop.label" = "Voltage drop budget"; +"editor.system.voltage_drop.hint.critical" = "3 % — for critical circuits like navigation, bilge pumps and electronics."; +"editor.system.voltage_drop.hint.standard" = "5 % — balanced default for mixed installations."; +"editor.system.voltage_drop.hint.noncritical" = "10 % — for non-critical loads such as cabin lighting."; +"settings.voltage_drop.label" = "Voltage drop budget"; +"settings.voltage_drop.footnote" = "Used by the calculator and for new systems. Existing systems keep their own budget."; diff --git a/Cable/Loads/CableCalculator.swift b/Cable/Loads/CableCalculator.swift index bd9ecf8..6fcba0b 100644 --- a/Cable/Loads/CableCalculator.swift +++ b/Cable/Loads/CableCalculator.swift @@ -16,6 +16,7 @@ class CableCalculator: ObservableObject { @Published var loadName: String = String(localized: "default.load.name", comment: "Default placeholder name for a load") @Published var dutyCyclePercent: Double = 100.0 @Published var dailyUsageHours: Double = 24.0 + @Published var maxVoltageDropPercent: Double = ElectricalCalculations.defaultMaxVoltageDropPercent var calculatedPower: Double { voltage * current @@ -38,38 +39,42 @@ class CableCalculator: ObservableObject { length: length, current: current, voltage: voltage, - unitSystem: unitSystem + unitSystem: unitSystem, + maxVoltageDropPercent: maxVoltageDropPercent ) } - + func crossSection(for unitSystem: UnitSystem) -> Double { recommendedCrossSection(for: unitSystem) } - + func voltageDrop(for unitSystem: UnitSystem) -> Double { ElectricalCalculations.voltageDrop( length: length, current: current, voltage: voltage, - unitSystem: unitSystem + unitSystem: unitSystem, + maxVoltageDropPercent: maxVoltageDropPercent ) } - + func voltageDropPercentage(for unitSystem: UnitSystem) -> Double { ElectricalCalculations.voltageDropPercentage( length: length, current: current, voltage: voltage, - unitSystem: unitSystem + unitSystem: unitSystem, + maxVoltageDropPercent: maxVoltageDropPercent ) } - + func powerLoss(for unitSystem: UnitSystem) -> Double { ElectricalCalculations.powerLoss( length: length, current: current, voltage: voltage, - unitSystem: unitSystem + unitSystem: unitSystem, + maxVoltageDropPercent: maxVoltageDropPercent ) } @@ -92,6 +97,9 @@ class ElectricalSystem { var colorName: String = "blue" var targetRuntimeHours: Double? var targetChargeTimeHours: Double? + /// Share of system voltage that may be lost in a cable. Stored per system so a critical + /// 12 V circuit can be planned at 3 % while a cabin light stays at 10 %. + var maxVoltageDropPercent: Double = ElectricalCalculations.defaultMaxVoltageDropPercent init( name: String, @@ -99,7 +107,8 @@ class ElectricalSystem { iconName: String = "building.2", colorName: String = "blue", targetRuntimeHours: Double? = nil, - targetChargeTimeHours: Double? = nil + targetChargeTimeHours: Double? = nil, + maxVoltageDropPercent: Double = ElectricalCalculations.defaultMaxVoltageDropPercent ) { self.name = name self.location = location @@ -108,6 +117,7 @@ class ElectricalSystem { self.colorName = colorName self.targetRuntimeHours = targetRuntimeHours self.targetChargeTimeHours = targetChargeTimeHours + self.maxVoltageDropPercent = maxVoltageDropPercent } } diff --git a/Cable/Loads/CalculatorView.swift b/Cable/Loads/CalculatorView.swift index 1fbefce..b3799fe 100644 --- a/Cable/Loads/CalculatorView.swift +++ b/Cable/Loads/CalculatorView.swift @@ -345,10 +345,14 @@ struct CalculatorView: View { .sheet(isPresented: $showingLoadEditor, content: loadEditorSheet) .sheet(item: $presentedAffiliateLink, content: billOfMaterialsSheet(info:)) .onAppear { + calculator.maxVoltageDropPercent = unitSettings.voltageDropTargetPercent if let savedLoad = savedLoad { loadConfiguration(from: savedLoad) } } + .onChange(of: unitSettings.voltageDropTargetPercent) { _, newValue in + calculator.maxVoltageDropPercent = newValue + } .onChange(of: completedItemIDs) { _, _ in persistCompletedItems() } @@ -701,7 +705,7 @@ struct CalculatorView: View { Button(action: {}) { Text(String(format: "%.1fV (%.1f%%)", calculator.voltageDrop(for: unitSettings.unitSystem), calculator.voltageDropPercentage(for: unitSettings.unitSystem))) .fontWeight(.medium) - .foregroundColor(calculator.voltageDropPercentage(for: unitSettings.unitSystem) > 5 ? .orange : .primary) + .foregroundColor(calculator.voltageDropPercentage(for: unitSettings.unitSystem) > calculator.maxVoltageDropPercent ? .orange : .primary) } .buttonStyle(.plain) diff --git a/Cable/Loads/ElectricalCalculations.swift b/Cable/Loads/ElectricalCalculations.swift index 238bc81..6996dea 100644 --- a/Cable/Loads/ElectricalCalculations.swift +++ b/Cable/Loads/ElectricalCalculations.swift @@ -8,7 +8,9 @@ import Foundation struct ElectricalCalculations { - private static let maxVoltageDropFraction = 0.05 + /// Default share of system voltage that may be lost in the cable. ABYC E-11 works with + /// 3 % for critical circuits and 10 % for non-critical ones; 5 % is the middle ground. + static let defaultMaxVoltageDropPercent = 5.0 private static let copperResistivity = 0.017 // Ω⋅mm²/m private static let feetToMeters = 0.3048 @@ -29,27 +31,87 @@ struct ElectricalCalculations { 300, 350, 400, 450, 500, 600, 700, 800, ] + /// Ampacity of the AWG ladder in amps. Source: ABYC E-11 Table 6A, 105 °C insulation + /// rating, outside engine spaces, single conductors not bundled. Index-aligned with + /// `standardAWG`. AWG 20 is not covered by the table, so it is never selected on + /// ampacity grounds. + private static let awgAmpacity: [Double] = [ + 0, 20, 25, 35, 45, 60, 80, 120, 160, 210, 245, 285, 330, 385, 445, + ] + + /// Ampacity of the metric ladder in amps. Source: ISO 13297 Table A1, 105 °C insulation + /// rating, bundles up to three conductors. The standard tabulates 0.75–150 mm²; larger + /// cross-sections inherit the 150 mm² value as a conservative lower bound. Index-aligned + /// with `standardMetricCrossSections`. + private static let metricAmpacity: [Double] = [ + 16, 20, 25, 35, 45, 60, 90, 130, 170, 210, 270, 330, 390, 450, 475, + 475, 475, 475, 475, 475, 475, + ] + + /// Current-carrying capacity of a cable in amps. `crossSection` is mm² for `.metric` + /// and an AWG value (see `standardAWG`) for `.imperial`. Cross-sections between two + /// standard sizes are rated with the smaller neighbour. + static func ampacity(forCrossSection crossSection: Double, unitSystem: UnitSystem) -> Double { + if unitSystem == .imperial { + guard let index = standardAWG.firstIndex(of: Int(crossSection)) else { return 0 } + return awgAmpacity[index] + } + var rating = 0.0 + for (index, area) in standardMetricCrossSections.enumerated() where area <= crossSection { + rating = metricAmpacity[index] + } + return rating + } + + /// Nearest AWG size for a stored metric cross-section. Used to display mm² values in + /// AWG; picks the closest ladder entry, not the next larger one. + static func nearestAWG(forCrossSectionMM2 crossSection: Double) -> Double { + guard crossSection > 0 else { return 0 } + var bestAWG = standardAWG.first ?? 20 + var bestDelta = Double.greatestFiniteMagnitude + for (index, area) in awgCrossSections.enumerated() { + let delta = abs(area - crossSection) + if delta < bestDelta { + bestDelta = delta + bestAWG = standardAWG[index] + } + } + return Double(bestAWG) + } + /// Length must always be in meters. unitSystem controls the output format (mm² vs AWG). + /// + /// The recommendation satisfies two independent criteria, whichever one demands more + /// copper: the voltage-drop budget, and an ampacity at least as high as the fuse that + /// protects the circuit. The latter follows ABYC E-11, which requires the rating of the + /// overcurrent protection device to not exceed the ampacity of the conductor it protects. static func recommendedCrossSection( length: Double, current: Double, voltage: Double, - unitSystem: UnitSystem + unitSystem: UnitSystem, + maxVoltageDropPercent: Double = defaultMaxVoltageDropPercent ) -> Double { let lengthInMeters = length - let maxVoltageDrop = voltage * maxVoltageDropFraction + let maxVoltageDrop = voltage * (maxVoltageDropPercent / 100) let minimumCrossSection = guardAgainstZero(maxVoltageDrop) { (2 * current * lengthInMeters * copperResistivity) / maxVoltageDrop } + let requiredAmpacity = recommendedFuse(forCurrent: current) if unitSystem == .imperial { - for (index, crossSection) in awgCrossSections.enumerated() where crossSection >= minimumCrossSection { + for (index, crossSection) in awgCrossSections.enumerated() + where crossSection >= minimumCrossSection && awgAmpacity[index] >= requiredAmpacity { return Double(standardAWG[index]) } return Double(standardAWG.last ?? 0) } else { - return standardMetricCrossSections.first { $0 >= max(standardMetricCrossSections.first ?? 0.75, minimumCrossSection) } - ?? standardMetricCrossSections.last ?? 0.75 + let minimumArea = max(standardMetricCrossSections.first ?? 0.75, minimumCrossSection) + for (index, crossSection) in standardMetricCrossSections.enumerated() + where crossSection >= minimumArea && metricAmpacity[index] >= requiredAmpacity { + return crossSection + } + return standardMetricCrossSections.last ?? 0.75 } } @@ -58,13 +120,15 @@ struct ElectricalCalculations { current: Double, voltage: Double, unitSystem: UnitSystem, - crossSection: Double? = nil + crossSection: Double? = nil, + maxVoltageDropPercent: Double = defaultMaxVoltageDropPercent ) -> Double { let selectedCrossSection = crossSection ?? recommendedCrossSection( length: length, current: current, voltage: voltage, - unitSystem: unitSystem + unitSystem: unitSystem, + maxVoltageDropPercent: maxVoltageDropPercent ) let lengthInMeters = length @@ -84,7 +148,8 @@ struct ElectricalCalculations { current: Double, voltage: Double, unitSystem: UnitSystem, - crossSection: Double? = nil + crossSection: Double? = nil, + maxVoltageDropPercent: Double = defaultMaxVoltageDropPercent ) -> Double { guard voltage != 0 else { return 0 } let drop = voltageDrop( @@ -92,7 +157,8 @@ struct ElectricalCalculations { current: current, voltage: voltage, unitSystem: unitSystem, - crossSection: crossSection + crossSection: crossSection, + maxVoltageDropPercent: maxVoltageDropPercent ) return (drop / voltage) * 100 } @@ -102,14 +168,16 @@ struct ElectricalCalculations { current: Double, voltage: Double, unitSystem: UnitSystem, - crossSection: Double? = nil + crossSection: Double? = nil, + maxVoltageDropPercent: Double = defaultMaxVoltageDropPercent ) -> Double { let drop = voltageDrop( length: length, current: current, voltage: voltage, unitSystem: unitSystem, - crossSection: crossSection + crossSection: crossSection, + maxVoltageDropPercent: maxVoltageDropPercent ) return current * drop } diff --git a/Cable/Loads/LoadCableSync.swift b/Cable/Loads/LoadCableSync.swift new file mode 100644 index 0000000..929a070 --- /dev/null +++ b/Cable/Loads/LoadCableSync.swift @@ -0,0 +1,32 @@ +import Foundation + +/// The cross-section of a load is derived, never entered by the user: it follows from length, +/// current, voltage and the system's voltage-drop budget. Whenever one of those inputs changes, +/// the stored value has to follow — otherwise the app displays a size it no longer stands behind +/// and would have to ask the user to fix something only the app can compute. +enum LoadCableSync { + /// Sets every load's cross-section to the size the app would recommend right now. + /// Returns how many loads changed, so callers can log or react without diffing themselves. + @discardableResult + static func synchronize(loads: [SavedLoad], maxVoltageDropPercent: Double) -> Int { + var changed = 0 + for load in loads where load.length > 0 && load.current > 0 { + let recommended = ElectricalCalculations.recommendedCrossSection( + length: load.length, + current: load.current, + voltage: load.voltage, + unitSystem: .metric, + maxVoltageDropPercent: maxVoltageDropPercent + ) + guard abs(load.crossSection - recommended) > 0.0001 else { continue } + load.crossSection = recommended + changed += 1 + } + return changed + } + + @discardableResult + static func synchronize(loads: [SavedLoad], for system: ElectricalSystem) -> Int { + synchronize(loads: loads, maxVoltageDropPercent: system.maxVoltageDropPercent) + } +} diff --git a/Cable/Loads/LoadConfigurationStatus.swift b/Cable/Loads/LoadConfigurationStatus.swift index 54143d2..d907c47 100644 --- a/Cable/Loads/LoadConfigurationStatus.swift +++ b/Cable/Loads/LoadConfigurationStatus.swift @@ -1,8 +1,21 @@ import SwiftUI +/// The only load problem the app cannot solve on its own: missing length or current. +/// Cross-sections are derived and kept in sync by `LoadCableSync`, so an undersized cable +/// can no longer occur — there is nothing to warn about that the user would have to fix. enum LoadConfigurationStatus: Identifiable, Equatable { case missingDetails(count: Int) + static func evaluate(loads: [SavedLoad]) -> LoadConfigurationStatus? { + guard !loads.isEmpty else { return nil } + + let incomplete = loads.filter { load in + load.length <= 0 || load.current <= 0 || load.crossSection <= 0 + } + guard !incomplete.isEmpty else { return nil } + return .missingDetails(count: incomplete.count) + } + var id: String { switch self { case .missingDetails(let count): @@ -43,7 +56,7 @@ enum LoadConfigurationStatus: Identifiable, Equatable { ) let format = String( localized: "loads.overview.status.missing_details.message", - defaultValue: "Enter cable length and wire size for %d %@ to see accurate recommendations." + defaultValue: "Enter cable length and current draw for %d %@ so Cable can size the wiring." ) let loadWord = count == 1 ? String( @@ -54,8 +67,7 @@ enum LoadConfigurationStatus: Identifiable, Equatable { localized: "loads.overview.status.missing_details.plural", defaultValue: "loads" ) - let message = String(format: format, count, loadWord) - return LoadStatusDetail(title: title, message: message) + return LoadStatusDetail(title: title, message: String(format: format, count, loadWord)) } } } diff --git a/Cable/Loads/LoadsView.swift b/Cable/Loads/LoadsView.swift index b36d6fd..21fd77d 100644 --- a/Cable/Loads/LoadsView.swift +++ b/Cable/Loads/LoadsView.swift @@ -145,6 +145,7 @@ struct LoadsView: View { .foregroundColor(.primary) } } + .accessibilityIdentifier("system-title-button") } ToolbarItem(placement: .navigationBarTrailing) { @@ -302,6 +303,18 @@ struct LoadsView: View { colorName: Binding( get: { system.colorName }, set: { system.colorName = $0 } + ), + maxVoltageDropPercent: Binding( + get: { system.maxVoltageDropPercent }, + set: { newValue in + system.maxVoltageDropPercent = newValue + // The budget is an input to every cable size, so the stored sizes follow. + let resized = LoadCableSync.synchronize(loads: savedLoads, for: system) + AnalyticsTracker.log("Voltage Drop Budget Changed", properties: [ + "percent": newValue, + "resized_loads": resized, + ]) + } ) ) } @@ -318,6 +331,8 @@ struct LoadsView: View { ) } .onAppear { + LoadCableSync.synchronize(loads: savedLoads, for: system) + if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear { hasPresentedSystemEditorOnAppear = true DispatchQueue.main.async { @@ -761,14 +776,7 @@ struct LoadsView: View { } private var loadStatus: LoadConfigurationStatus? { - guard !savedLoads.isEmpty else { return nil } - let incompleteLoads = savedLoads.filter { load in - load.length <= 0 || load.crossSection <= 0 - } - if !incompleteLoads.isEmpty { - return .missingDetails(count: incompleteLoads.count) - } - return nil + LoadConfigurationStatus.evaluate(loads: savedLoads) } private func summaryMetric(icon: String, label: String, value: String, tint: Color) -> some View { @@ -1102,13 +1110,7 @@ struct LoadsView: View { } private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double { - let awgSizes: [(Int, Double)] = [(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31), - (10, 5.26), (8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6), - (1, 42.4), (-1, 53.5), (-2, 67.4), (-3, 85.0), (-4, 107.0)] - - // Find the closest AWG size - let closest = awgSizes.min { abs($0.1 - crossSectionMM2) < abs($1.1 - crossSectionMM2) } - return Double(closest?.0 ?? 20) + ElectricalCalculations.nearestAWG(forCrossSectionMM2: crossSectionMM2) } private func recommendedFuse(for load: SavedLoad) -> String { @@ -1218,16 +1220,20 @@ struct LoadsView: View { dutyCyclePercent: load.dutyCyclePercent, dailyUsageHours: load.dailyUsageHours, recommendedCrossSection: ElectricalCalculations.recommendedCrossSection( - length: load.length, current: load.current, voltage: load.voltage, unitSystem: currentUnitSystem + length: load.length, current: load.current, voltage: load.voltage, + unitSystem: currentUnitSystem, maxVoltageDropPercent: system.maxVoltageDropPercent ), voltageDrop: ElectricalCalculations.voltageDrop( - length: load.length, current: load.current, voltage: load.voltage, unitSystem: currentUnitSystem + length: load.length, current: load.current, voltage: load.voltage, + unitSystem: currentUnitSystem, maxVoltageDropPercent: system.maxVoltageDropPercent ), voltageDropPercent: ElectricalCalculations.voltageDropPercentage( - length: load.length, current: load.current, voltage: load.voltage, unitSystem: currentUnitSystem + length: load.length, current: load.current, voltage: load.voltage, + unitSystem: currentUnitSystem, maxVoltageDropPercent: system.maxVoltageDropPercent ), powerLoss: ElectricalCalculations.powerLoss( - length: load.length, current: load.current, voltage: load.voltage, unitSystem: currentUnitSystem + length: load.length, current: load.current, voltage: load.voltage, + unitSystem: currentUnitSystem, maxVoltageDropPercent: system.maxVoltageDropPercent ), recommendedFuse: ElectricalCalculations.recommendedFuse(forCurrent: load.current), iconUrl: load.remoteIconURLString diff --git a/Cable/Overview/SystemOverviewView.swift b/Cable/Overview/SystemOverviewView.swift index 0f91670..a75aaba 100644 --- a/Cable/Overview/SystemOverviewView.swift +++ b/Cable/Overview/SystemOverviewView.swift @@ -4,6 +4,7 @@ import SwiftData struct SystemOverviewView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext + @State private var activeStatus: LoadConfigurationStatus? @State private var suppressLoadNavigation = false @State private var showingRuntimeGoalEditor = false @@ -390,7 +391,6 @@ struct SystemOverviewView: View { .fill(Color(.tertiarySystemBackground)) ) } - .buttonStyle(.plain) .alert(item: $activeStatus) { status in let detail = status.detailInfo() return Alert( @@ -588,14 +588,7 @@ struct SystemOverviewView: View { } private var loadStatus: LoadConfigurationStatus? { - guard !loads.isEmpty else { return nil } - let incomplete = loads.filter { load in - load.length <= 0 || load.crossSection <= 0 - } - if !incomplete.isEmpty { - return .missingDetails(count: incomplete.count) - } - return nil + LoadConfigurationStatus.evaluate(loads: loads) } private var totalCurrent: Double { diff --git a/Cable/SettingsView.swift b/Cable/SettingsView.swift index 94d66d2..df778fe 100644 --- a/Cable/SettingsView.swift +++ b/Cable/SettingsView.swift @@ -24,6 +24,28 @@ struct SettingsView: View { } .pickerStyle(.segmented) } + Section { + Picker( + String( + localized: "settings.voltage_drop.label", + defaultValue: "Voltage drop budget" + ), + selection: $unitSettings.voltageDropTargetPercent + ) { + ForEach([3.0, 5.0, 10.0], id: \.self) { target in + Text(String(format: "%.0f %%", target)).tag(target) + } + } + .pickerStyle(.segmented) + Text( + String( + localized: "settings.voltage_drop.footnote", + defaultValue: "Used by the calculator and for new systems. Existing systems keep their own budget." + ) + ) + .font(.caption) + .foregroundStyle(.secondary) + } Section { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { diff --git a/Cable/Systems/SystemBillOfMaterialsView.swift b/Cable/Systems/SystemBillOfMaterialsView.swift index 61c6570..bf0e58a 100644 --- a/Cable/Systems/SystemBillOfMaterialsView.swift +++ b/Cable/Systems/SystemBillOfMaterialsView.swift @@ -852,19 +852,7 @@ struct SystemBillOfMaterialsView: View { } private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double { - let mapping: [(awg: Double, area: Double)] = [ - (20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31), (10, 5.26), - (8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6), (1, 42.4), (-1, 53.5), - (-2, 67.4), (-3, 85.0), (-4, 107.0) - ] - - guard crossSectionMM2 > 0 else { return 0 } - - let closest = mapping.min { lhs, rhs in - abs(lhs.area - crossSectionMM2) < abs(rhs.area - crossSectionMM2) - } - - return closest?.awg ?? 0 + ElectricalCalculations.nearestAWG(forCrossSectionMM2: crossSectionMM2) } private var footerMessage: String { diff --git a/Cable/Systems/SystemComponentsPersistence.swift b/Cable/Systems/SystemComponentsPersistence.swift index ceed1bc..f1cae57 100644 --- a/Cable/Systems/SystemComponentsPersistence.swift +++ b/Cable/Systems/SystemComponentsPersistence.swift @@ -20,13 +20,22 @@ struct SystemComponentsPersistence { batteries: existingBatteries, chargers: existingChargers ) + let defaultLength = 10.0 + let defaultVoltage = 12.0 + let defaultCurrent = 5.0 let newLoad = SavedLoad( name: loadName, - voltage: 12.0, - current: 5.0, - power: 60.0, - length: 10.0, - crossSection: 1.0, + voltage: defaultVoltage, + current: defaultCurrent, + power: defaultVoltage * defaultCurrent, + length: defaultLength, + crossSection: ElectricalCalculations.recommendedCrossSection( + length: defaultLength, + current: defaultCurrent, + voltage: defaultVoltage, + unitSystem: .metric, + maxVoltageDropPercent: system.maxVoltageDropPercent + ), iconName: "lightbulb", colorName: "blue", isWattMode: false, @@ -69,13 +78,20 @@ struct SystemComponentsPersistence { let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100 let dailyUsageHours = item.defaultDailyUsageHours ?? 1 + let length = 10.0 let newLoad = SavedLoad( name: loadName, voltage: voltage, current: current, power: power, - length: 10.0, - crossSection: 1.0, + length: length, + crossSection: ElectricalCalculations.recommendedCrossSection( + length: length, + current: current, + voltage: voltage, + unitSystem: .metric, + maxVoltageDropPercent: system.maxVoltageDropPercent + ), iconName: "lightbulb", colorName: "blue", isWattMode: item.watt != nil, diff --git a/Cable/Systems/SystemEditorView.swift b/Cable/Systems/SystemEditorView.swift index 98f7924..f87d35a 100644 --- a/Cable/Systems/SystemEditorView.swift +++ b/Cable/Systems/SystemEditorView.swift @@ -12,6 +12,7 @@ struct SystemEditorView: View { @Binding var location: String @Binding var iconName: String @Binding var colorName: String + @Binding var maxVoltageDropPercent: Double @State private var tempLocation: String @@ -23,11 +24,22 @@ struct SystemEditorView: View { "engine.combustion", "fuelpump", "drop", "flame", "snowflake", "thermometer" ] - init(systemName: Binding, location: Binding, iconName: Binding, colorName: Binding) { + /// Targets offered as presets: ABYC E-11 uses 3 % for critical circuits and 10 % for + /// non-critical ones, 5 % is the default middle ground. + private let voltageDropTargets: [Double] = [3, 5, 10] + + init( + systemName: Binding, + location: Binding, + iconName: Binding, + colorName: Binding, + maxVoltageDropPercent: Binding + ) { self._systemName = systemName self._location = location self._iconName = iconName self._colorName = colorName + self._maxVoltageDropPercent = maxVoltageDropPercent self._tempLocation = State(initialValue: location.wrappedValue) } @@ -46,11 +58,41 @@ struct SystemEditorView: View { colorName: $colorName, additionalFields: { AnyView( - TextField(locationPlaceholder, text: $tempLocation) - .autocapitalization(.words) - .onChange(of: tempLocation) { _, newValue in - location = newValue + VStack(alignment: .leading, spacing: 12) { + TextField(locationPlaceholder, text: $tempLocation) + .autocapitalization(.words) + .onChange(of: tempLocation) { _, newValue in + location = newValue + } + + VStack(alignment: .leading, spacing: 6) { + Text( + String( + localized: "editor.system.voltage_drop.label", + defaultValue: "Voltage drop budget" + ) + ) + .font(.subheadline.weight(.semibold)) + + Picker( + String( + localized: "editor.system.voltage_drop.label", + defaultValue: "Voltage drop budget" + ), + selection: $maxVoltageDropPercent + ) { + ForEach(voltageDropTargets, id: \.self) { target in + Text(String(format: "%.0f %%", target)).tag(target) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("system-voltage-drop-picker") + + Text(voltageDropHint) + .font(.caption) + .foregroundStyle(.secondary) } + } ) } ) @@ -58,6 +100,26 @@ struct SystemEditorView: View { tempLocation = location } } + + private var voltageDropHint: String { + switch maxVoltageDropPercent { + case ..<4: + return String( + localized: "editor.system.voltage_drop.hint.critical", + defaultValue: "3 % — for critical circuits like navigation, bilge pumps and electronics." + ) + case ..<6: + return String( + localized: "editor.system.voltage_drop.hint.standard", + defaultValue: "5 % — balanced default for mixed installations." + ) + default: + return String( + localized: "editor.system.voltage_drop.hint.noncritical", + defaultValue: "10 % — for non-critical loads such as cabin lighting." + ) + } + } } #Preview { @@ -65,6 +127,13 @@ struct SystemEditorView: View { @Previewable @State var location = "Main Building" @Previewable @State var icon = "building.2" @Previewable @State var color = "blue" - - return SystemEditorView(systemName: $name, location: $location, iconName: $icon, colorName: $color) + @Previewable @State var dropTarget = 5.0 + + return SystemEditorView( + systemName: $name, + location: $location, + iconName: $icon, + colorName: $color, + maxVoltageDropPercent: $dropTarget + ) } diff --git a/Cable/Systems/SystemsView.swift b/Cable/Systems/SystemsView.swift index 348e301..fab40a6 100644 --- a/Cable/Systems/SystemsView.swift +++ b/Cable/Systems/SystemsView.swift @@ -324,7 +324,8 @@ struct SystemsView: View { name: systemName, location: "", iconName: resolvedIconName, - colorName: resolvedColorName + colorName: resolvedColorName, + maxVoltageDropPercent: unitSettings.voltageDropTargetPercent ) modelContext.insert(newSystem) return newSystem diff --git a/Cable/UnitSystem.swift b/Cable/UnitSystem.swift index 0a562d8..fdea410 100644 --- a/Cable/UnitSystem.swift +++ b/Cable/UnitSystem.swift @@ -58,6 +58,13 @@ class UnitSystemSettings: ObservableObject { AnalyticsTracker.log("Unit System Changed", properties: ["system": unitSystem.rawValue]) } } + /// Applies to the standalone calculator and seeds newly created systems. + @Published var voltageDropTargetPercent: Double { + didSet { + UserDefaults.standard.set(voltageDropTargetPercent, forKey: "voltageDropTargetPercent") + AnalyticsTracker.log("Voltage Drop Target Changed", properties: ["percent": voltageDropTargetPercent]) + } + } init() { if let saved = UserDefaults.standard.string(forKey: "unitSystem"), @@ -66,5 +73,9 @@ class UnitSystemSettings: ObservableObject { } else { self.unitSystem = Locale.current.measurementSystem == .us ? .imperial : .metric } + let savedTarget = UserDefaults.standard.double(forKey: "voltageDropTargetPercent") + self.voltageDropTargetPercent = savedTarget > 0 + ? savedTarget + : ElectricalCalculations.defaultMaxVoltageDropPercent } } diff --git a/Cable/de.lproj/Localizable.strings b/Cable/de.lproj/Localizable.strings index ef7d9b2..d7f1cc7 100644 --- a/Cable/de.lproj/Localizable.strings +++ b/Cable/de.lproj/Localizable.strings @@ -177,10 +177,6 @@ "bom.quantity.terminal.badge" = "%1$d× · %2$@"; "bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@"; "bom.quantity.single.badge" = "1× • %@"; -"cable.pro.privacy.label" = "Datenschutz"; -"cable.pro.privacy.url" = "https://voltplan.app/de/datenschutz"; -"cable.pro.terms.label" = "Nutzungsbedingungen"; -"cable.pro.terms.url" = "https://voltplan.app/de/agb"; "calculator.advanced.duty_cycle.helper" = "Prozentsatz der aktiven Zeit, in der die Last tatsächlich Leistung aufnimmt."; "calculator.advanced.duty_cycle.title" = "Einschaltdauer"; "calculator.advanced.section.title" = "Erweitert"; @@ -259,7 +255,7 @@ "loads.overview.metric.current" = "Strom"; "loads.overview.metric.power" = "Leistung"; "loads.overview.status.missing_details.banner" = "Konfiguration deiner Verbraucher abschließen"; -"loads.overview.status.missing_details.message" = "Gib Kabellänge und Leitungsquerschnitt für %d %@ ein, um genaue Empfehlungen zu erhalten."; +"loads.overview.status.missing_details.message" = "Gib Kabellänge und Stromaufnahme für %d %@ ein, damit Cable die Leitung dimensionieren kann."; "loads.overview.status.missing_details.plural" = "Verbraucher"; "loads.overview.status.missing_details.singular" = "Verbraucher"; "loads.overview.status.missing_details.title" = "Fehlende Verbraucherdetails"; @@ -349,47 +345,7 @@ "settings.pro.manage.url" = "https://apps.apple.com/account/subscriptions"; "settings.pro.day.one" = "%@ Tag"; "settings.pro.day.other" = "%@ Tage"; -"cable.pro.terms.label" = "AGB"; -"cable.pro.privacy.label" = "Datenschutz"; -"cable.pro.terms.url" = "https://voltplan.app/terms"; -"cable.pro.privacy.url" = "https://voltplan.app/privacy"; -"cable.pro.paywall.title" = "Cable PRO"; -"cable.pro.paywall.subtitle" = "Cable PRO bietet mehr Konfigurationsoptionen für Verbraucher, Batterien und Ladegeräte."; -"cable.pro.feature.dutyCycle" = "Kabelberechnungen mit Einschaltdauer"; -"cable.pro.feature.batteryCapacity" = "Verfügbare Batteriekapazität konfigurieren"; -"cable.pro.feature.usageBased" = "Nutzungsbasierte Berechnungen"; -"cable.pro.button.unlock" = "Jetzt freischalten"; -"cable.pro.button.freeTrial" = "Kostenlose Testphase starten"; -"cable.pro.button.unlocked" = "Bereits aktiviert"; -"cable.pro.restore.button" = "Käufe wiederherstellen"; -"cable.pro.alert.success.title" = "Cable PRO aktiviert"; -"cable.pro.alert.success.body" = "Danke für deine Unterstützung!"; -"cable.pro.alert.pending.title" = "Kauf ausstehend"; -"cable.pro.alert.pending.body" = "Dein Kauf wartet auf Bestätigung."; -"cable.pro.alert.restored.title" = "Käufe wiederhergestellt"; -"cable.pro.alert.restored.body" = "Deine bisherigen Käufe sind wieder verfügbar."; -"cable.pro.alert.error.title" = "Kauf fehlgeschlagen"; -"cable.pro.alert.error.generic" = "Etwas ist schiefgelaufen. Bitte versuche es erneut."; "generic.ok" = "OK"; -"cable.pro.trial.badge" = "Enthält eine %@ Testphase"; -"cable.pro.subscription.renews" = "Verlängert sich %@."; -"cable.pro.subscription.trialThenRenews" = "Testphase, danach Verlängerung %@."; -"cable.pro.duration.day.singular" = "jeden Tag"; -"cable.pro.duration.day.plural" = "alle %@ Tage"; -"cable.pro.duration.week.singular" = "jede Woche"; -"cable.pro.duration.week.plural" = "alle %@ Wochen"; -"cable.pro.duration.month.singular" = "monatlich"; -"cable.pro.duration.month.plural" = "alle %@ Monate"; -"cable.pro.duration.year.singular" = "jährlich"; -"cable.pro.duration.year.plural" = "alle %@ Jahre"; -"cable.pro.trial.duration.day.singular" = "%@-tägige"; -"cable.pro.trial.duration.day.plural" = "%@-tägige"; -"cable.pro.trial.duration.week.singular" = "%@-wöchige"; -"cable.pro.trial.duration.week.plural" = "%@-wöchige"; -"cable.pro.trial.duration.month.singular" = "%@-monatige"; -"cable.pro.trial.duration.month.plural" = "%@-monatige"; -"cable.pro.trial.duration.year.singular" = "%@-jährige"; -"cable.pro.trial.duration.year.plural" = "%@-jährige"; "• Always consult qualified electricians for actual installations" = "• Ziehe für tatsächliche Installationen stets qualifizierte Elektriker hinzu"; "• Electrical work should only be performed by licensed professionals" = "• Elektroarbeiten sollten nur von zertifizierten Fachkräften ausgeführt werden"; "• Follow all local electrical codes and regulations" = "• Beachte alle örtlichen Vorschriften und Normen"; @@ -453,3 +409,10 @@ "overview.share.diagram" = "Schaltplan"; "overview.share.pdf" = "Vollständiger Bericht (PDF)"; "overview.share.diagram.error" = "Schaltplan konnte nicht erstellt werden. Überprüfe deine Internetverbindung."; + +"editor.system.voltage_drop.label" = "Spannungsfall-Budget"; +"editor.system.voltage_drop.hint.critical" = "3 % — für kritische Stromkreise wie Navigation, Bilgepumpe und Elektronik."; +"editor.system.voltage_drop.hint.standard" = "5 % — ausgewogener Standard für gemischte Installationen."; +"editor.system.voltage_drop.hint.noncritical" = "10 % — für unkritische Verbraucher wie Innenbeleuchtung."; +"settings.voltage_drop.label" = "Spannungsfall-Budget"; +"settings.voltage_drop.footnote" = "Gilt für den Rechner und neue Systeme. Bestehende Systeme behalten ihr eigenes Budget."; diff --git a/Cable/es.lproj/Localizable.strings b/Cable/es.lproj/Localizable.strings index ec1d0fb..d7f3659 100644 --- a/Cable/es.lproj/Localizable.strings +++ b/Cable/es.lproj/Localizable.strings @@ -196,7 +196,7 @@ "loads.onboarding.title" = "Añade tu primer consumidor"; "loads.onboarding.subtitle" = "Completa tu sistema con consumidores y deja que **Cable by VoltPlan** calcule cables y fusibles por ti."; "loads.overview.status.missing_details.title" = "Faltan detalles de la carga"; -"loads.overview.status.missing_details.message" = "Introduce la longitud del cable y el calibre del conductor para %d %@ para obtener recomendaciones precisas."; +"loads.overview.status.missing_details.message" = "Introduce la longitud del cable y la corriente de %d %@ para que Cable dimensione el cableado."; "loads.overview.status.missing_details.singular" = "carga"; "loads.overview.status.missing_details.plural" = "cargas"; "loads.overview.status.missing_details.banner" = "Completa la configuración de tus cargas"; @@ -351,11 +351,6 @@ "chargers.title" = "Cargadores para %@"; "chargers.subtitle" = "Los componentes de carga estarán disponibles pronto."; -"cable.pro.paywall.title" = "Cable PRO"; -"cable.pro.paywall.subtitle" = "Cable PRO permite más opciones de configuración para cargas, baterías y cargadores."; -"cable.pro.feature.dutyCycle" = "Calculadoras de cables conscientes del ciclo de trabajo"; -"cable.pro.feature.batteryCapacity" = "Configura la capacidad utilizable de la batería"; -"cable.pro.feature.usageBased" = "Cálculos basados en el uso"; "generic.ok" = "Aceptar"; // MARK: - PDF Overview Export @@ -415,3 +410,10 @@ "overview.share.diagram" = "Diagrama de cableado"; "overview.share.pdf" = "Informe completo (PDF)"; "overview.share.diagram.error" = "No se pudo generar el diagrama. Comprueba tu conexión a Internet."; + +"editor.system.voltage_drop.label" = "Caída de tensión admisible"; +"editor.system.voltage_drop.hint.critical" = "3 % — para circuitos críticos como navegación, bombas de sentina y electrónica."; +"editor.system.voltage_drop.hint.standard" = "5 % — valor equilibrado para instalaciones mixtas."; +"editor.system.voltage_drop.hint.noncritical" = "10 % — para consumos no críticos como la iluminación interior."; +"settings.voltage_drop.label" = "Caída de tensión admisible"; +"settings.voltage_drop.footnote" = "Se aplica a la calculadora y a los sistemas nuevos. Los sistemas existentes conservan su valor."; diff --git a/Cable/fr.lproj/Localizable.strings b/Cable/fr.lproj/Localizable.strings index bc40033..0798105 100644 --- a/Cable/fr.lproj/Localizable.strings +++ b/Cable/fr.lproj/Localizable.strings @@ -196,7 +196,7 @@ "loads.onboarding.title" = "Ajoutez votre premier consommateur"; "loads.onboarding.subtitle" = "Complétez votre système avec des équipements et laissez **Cable by VoltPlan** proposer les câbles et fusibles adaptés."; "loads.overview.status.missing_details.title" = "Détails de charge manquants"; -"loads.overview.status.missing_details.message" = "Saisissez la longueur de câble et la section du conducteur pour %d %@ afin d'obtenir des recommandations précises."; +"loads.overview.status.missing_details.message" = "Indiquez la longueur de câble et le courant de %d %@ pour que Cable dimensionne le câblage."; "loads.overview.status.missing_details.singular" = "charge"; "loads.overview.status.missing_details.plural" = "charges"; "loads.overview.status.missing_details.banner" = "Terminez la configuration de vos charges"; @@ -351,11 +351,6 @@ "chargers.title" = "Chargeurs pour %@"; "chargers.subtitle" = "Les chargeurs seront bientôt disponibles ici."; -"cable.pro.paywall.title" = "Cable PRO"; -"cable.pro.paywall.subtitle" = "Cable PRO offre davantage d'options de configuration pour les charges, les batteries et les chargeurs."; -"cable.pro.feature.dutyCycle" = "Calculs de câbles tenant compte du cycle d'utilisation"; -"cable.pro.feature.batteryCapacity" = "Configurez la capacité utilisable de la batterie"; -"cable.pro.feature.usageBased" = "Calculs basés sur l’utilisation"; "generic.ok" = "OK"; // MARK: - PDF Overview Export @@ -415,3 +410,10 @@ "overview.share.diagram" = "Schéma de câblage"; "overview.share.pdf" = "Rapport complet (PDF)"; "overview.share.diagram.error" = "Impossible de générer le schéma. Vérifiez votre connexion Internet."; + +"editor.system.voltage_drop.label" = "Chute de tension admissible"; +"editor.system.voltage_drop.hint.critical" = "3 % — pour les circuits critiques : navigation, pompe de cale, électronique."; +"editor.system.voltage_drop.hint.standard" = "5 % — valeur équilibrée pour les installations mixtes."; +"editor.system.voltage_drop.hint.noncritical" = "10 % — pour les charges non critiques comme l’éclairage intérieur."; +"settings.voltage_drop.label" = "Chute de tension admissible"; +"settings.voltage_drop.footnote" = "S’applique au calculateur et aux nouvelles installations. Les installations existantes gardent leur valeur."; diff --git a/Cable/nl.lproj/Localizable.strings b/Cable/nl.lproj/Localizable.strings index ffde91d..497a627 100644 --- a/Cable/nl.lproj/Localizable.strings +++ b/Cable/nl.lproj/Localizable.strings @@ -196,7 +196,7 @@ "loads.onboarding.title" = "Voeg je eerste verbruiker toe"; "loads.onboarding.subtitle" = "Bouw je systeem uit met verbruikers en laat **Cable by VoltPlan** de kabel- en zekeringadviezen verzorgen."; "loads.overview.status.missing_details.title" = "Ontbrekende lastdetails"; -"loads.overview.status.missing_details.message" = "Voer kabellengte en kabeldoorsnede in voor %d %@ om nauwkeurige aanbevelingen te krijgen."; +"loads.overview.status.missing_details.message" = "Vul kabellengte en stroom van %d %@ in zodat Cable de kabel kan dimensioneren."; "loads.overview.status.missing_details.singular" = "last"; "loads.overview.status.missing_details.plural" = "lasten"; "loads.overview.status.missing_details.banner" = "Rond de configuratie van je lasten af"; @@ -351,11 +351,6 @@ "chargers.title" = "Laders voor %@"; "chargers.subtitle" = "Ladercomponenten zijn binnenkort beschikbaar."; -"cable.pro.paywall.title" = "Cable PRO"; -"cable.pro.paywall.subtitle" = "Cable PRO biedt meer configuratie-opties voor verbruikers, batterijen en laders."; -"cable.pro.feature.dutyCycle" = "Kabelberekeningen die rekening houden met de inschakelduur"; -"cable.pro.feature.batteryCapacity" = "Configureer bruikbare batterijcapaciteit"; -"cable.pro.feature.usageBased" = "Gebruiksgestuurde berekeningen"; "generic.ok" = "OK"; // MARK: - PDF Overview Export @@ -415,3 +410,10 @@ "overview.share.diagram" = "Bedradingsschema"; "overview.share.pdf" = "Volledig rapport (PDF)"; "overview.share.diagram.error" = "Diagram kon niet worden gegenereerd. Controleer je internetverbinding."; + +"editor.system.voltage_drop.label" = "Toegestane spanningsval"; +"editor.system.voltage_drop.hint.critical" = "3 % — voor kritische circuits zoals navigatie, bilgepomp en elektronica."; +"editor.system.voltage_drop.hint.standard" = "5 % — evenwichtige standaard voor gemengde installaties."; +"editor.system.voltage_drop.hint.noncritical" = "10 % — voor niet-kritische verbruikers zoals binnenverlichting."; +"settings.voltage_drop.label" = "Toegestane spanningsval"; +"settings.voltage_drop.footnote" = "Geldt voor de rekenhulp en nieuwe systemen. Bestaande systemen houden hun eigen waarde."; diff --git a/CableTests/CableTests.swift b/CableTests/CableTests.swift index 3634ee9..04d8d82 100644 --- a/CableTests/CableTests.swift +++ b/CableTests/CableTests.swift @@ -354,8 +354,9 @@ struct CableTests { ) // Minimum raw cross-section: (2×15×7.62×0.017)/(120×0.05) = 0.648mm² - // Metric: rounds to 0.75mm² (smallest standard ≥ 0.648) - #expect(metricMinCS == 0.75) + // Metric: 0.75mm² covers the voltage drop but only carries 16A (ISO 13297, 105 °C), + // which is below the 20A fuse for a 15A load, so ampacity forces 1.0mm² (20A). + #expect(metricMinCS == 1.0) // Imperial: AWG 18 (0.823mm² ≥ 0.648) #expect(imperialAWG == 18.0) } @@ -412,4 +413,208 @@ struct CableTests { #expect(abs(actualDrop - expectedDrop) < 0.001) #expect(abs(actualLoss - expectedPowerLoss) < 0.001) } + + // MARK: - Ampacity + + /// A short run makes voltage drop irrelevant, so before ampacity was considered the + /// recommendation was 1.5mm² / AWG 16 next to a 50A fuse — a cable the fuse cannot protect. + @Test func shortHighCurrentRunIsSizedForAmpacityNotVoltageDrop() async throws { + // 0.5m, 40A, 12V → voltage-drop minimum is only 1.13mm², fuse is 50A + #expect(ElectricalCalculations.recommendedFuse(forCurrent: 40) == 50.0) + + let metric = ElectricalCalculations.recommendedCrossSection( + length: 0.5, current: 40, voltage: 12, unitSystem: .metric + ) + // 4.0mm² carries 45A < 50A, so 6.0mm² (60A) is the smallest protected size + #expect(metric == 6.0) + + let imperial = ElectricalCalculations.recommendedCrossSection( + length: 0.5, current: 40, voltage: 12, unitSystem: .imperial + ) + // AWG 12 carries 45A < 50A, so AWG 10 (60A) is the smallest protected size + #expect(imperial == 10.0) + } + + @Test func fuseNeverExceedsCableAmpacity() async throws { + let scenarios: [(length: Double, current: Double, voltage: Double)] = [ + (0.3, 15, 12), (0.5, 40, 12), (1, 30, 12), (1, 60, 12), (2, 25, 12), + (3, 10, 12), (5, 5, 12), (8, 20, 24), (12, 50, 24), (10, 100, 48), + ] + + for scenario in scenarios { + let fuse = ElectricalCalculations.recommendedFuse(forCurrent: scenario.current) + + for unitSystem in [UnitSystem.metric, UnitSystem.imperial] { + let crossSection = ElectricalCalculations.recommendedCrossSection( + length: scenario.length, + current: scenario.current, + voltage: scenario.voltage, + unitSystem: unitSystem + ) + let ampacity = ElectricalCalculations.ampacity( + forCrossSection: crossSection, unitSystem: unitSystem + ) + #expect( + ampacity >= fuse, + "\(scenario.current)A over \(scenario.length)m: \(fuse)A fuse on a cable rated \(ampacity)A" + ) + } + } + } + + @Test func longRunsStayVoltageDropDriven() async throws { + // 3m, 10A, 12V → voltage drop needs 1.7mm², fuse is only 15A, so the drop still wins + let metric = ElectricalCalculations.recommendedCrossSection( + length: 3, current: 10, voltage: 12, unitSystem: .metric + ) + #expect(metric == 2.5) + #expect(ElectricalCalculations.ampacity(forCrossSection: 2.5, unitSystem: .metric) == 35.0) + } + + @Test func ampacityMatchesPublishedTables() async throws { + // ISO 13297 Table A1, 105 °C + #expect(ElectricalCalculations.ampacity(forCrossSection: 0.75, unitSystem: .metric) == 16.0) + #expect(ElectricalCalculations.ampacity(forCrossSection: 2.5, unitSystem: .metric) == 35.0) + #expect(ElectricalCalculations.ampacity(forCrossSection: 16.0, unitSystem: .metric) == 130.0) + // Between two standard sizes the smaller neighbour rates the cable + #expect(ElectricalCalculations.ampacity(forCrossSection: 3.0, unitSystem: .metric) == 35.0) + // ABYC E-11 Table 6A, 105 °C, outside engine spaces + #expect(ElectricalCalculations.ampacity(forCrossSection: 14, unitSystem: .imperial) == 35.0) + #expect(ElectricalCalculations.ampacity(forCrossSection: 6, unitSystem: .imperial) == 120.0) + #expect(ElectricalCalculations.ampacity(forCrossSection: -4, unitSystem: .imperial) == 445.0) + // AWG 20 is not covered by the table + #expect(ElectricalCalculations.ampacity(forCrossSection: 20, unitSystem: .imperial) == 0.0) + } + + // MARK: - Voltage Drop Target + + @Test func tighterTargetDemandsMoreCopper() async throws { + // 5m, 10A, 12V: 5% needs 2.833mm² → 4.0mm², 3% needs 4.722mm² → 6.0mm² + let standard = ElectricalCalculations.recommendedCrossSection( + length: 5, current: 10, voltage: 12, unitSystem: .metric + ) + let critical = ElectricalCalculations.recommendedCrossSection( + length: 5, current: 10, voltage: 12, unitSystem: .metric, maxVoltageDropPercent: 3 + ) + #expect(standard == 4.0) + #expect(critical == 6.0) + } + + @Test func looserTargetAllowsThinnerCableUntilAmpacityStops() async throws { + // 5m, 10A, 12V at 10%: drop needs only 1.417mm², but the 15A fuse still requires + // a cable rated for it, so 1.5mm² (25A per ISO 13297) is the floor. + let nonCritical = ElectricalCalculations.recommendedCrossSection( + length: 5, current: 10, voltage: 12, unitSystem: .metric, maxVoltageDropPercent: 10 + ) + #expect(nonCritical == 1.5) + #expect(ElectricalCalculations.ampacity(forCrossSection: 1.5, unitSystem: .metric) >= 15.0) + } + + @Test func targetIsRespectedByResultingDrop() async throws { + for target in [3.0, 5.0, 10.0] { + let crossSection = ElectricalCalculations.recommendedCrossSection( + length: 8, current: 20, voltage: 12, unitSystem: .metric, maxVoltageDropPercent: target + ) + let dropPercent = ElectricalCalculations.voltageDropPercentage( + length: 8, current: 20, voltage: 12, unitSystem: .metric, crossSection: crossSection + ) + #expect(dropPercent <= target, "target \(target)% produced \(dropPercent)%") + } + } + + @Test func defaultTargetStaysAtFivePercent() async throws { + #expect(ElectricalCalculations.defaultMaxVoltageDropPercent == 5.0) + let explicit = ElectricalCalculations.recommendedCrossSection( + length: 5, current: 10, voltage: 12, unitSystem: .metric, maxVoltageDropPercent: 5 + ) + let implicit = ElectricalCalculations.recommendedCrossSection( + length: 5, current: 10, voltage: 12, unitSystem: .metric + ) + #expect(explicit == implicit) + } + + @Test func systemStoresItsOwnTarget() async throws { + let system = ElectricalSystem(name: "Nav circuit", maxVoltageDropPercent: 3) + #expect(system.maxVoltageDropPercent == 3.0) + let defaultSystem = ElectricalSystem(name: "Cabin") + #expect(defaultSystem.maxVoltageDropPercent == ElectricalCalculations.defaultMaxVoltageDropPercent) + } + + // MARK: - Derived Cable Sizes + + /// `SavedLoad.crossSection` is always stored in mm², independent of the display unit. + private func makeLoad(current: Double, length: Double, crossSection: Double, voltage: Double = 12) -> SavedLoad { + SavedLoad( + name: "Load", + voltage: voltage, + current: current, + power: voltage * current, + length: length, + crossSection: crossSection + ) + } + + @Test func syncRepairsLibraryPlaceholderSizes() async throws { + // Library loads used to be stored with a hardcoded 1.0mm² at 10m + let load = makeLoad(current: 5, length: 10, crossSection: 1.0) + let changed = LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 5) + // 5 % needs (2×5×10×0.017)/(12×0.05) = 2.833mm² → next standard size is 4.0mm² + #expect(changed == 1) + #expect(load.crossSection == 4.0) + } + + @Test func syncRepairsCablesThatCannotCarryTheirFuse() async throws { + // 40A load → 50A fuse; 1.5mm² carries 25A (ISO 13297), so it has to grow to 6.0mm² (60A) + let load = makeLoad(current: 40, length: 0.5, crossSection: 1.5) + LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 5) + #expect(load.crossSection == 6.0) + #expect( + ElectricalCalculations.ampacity(forCrossSection: load.crossSection, unitSystem: .metric) + >= ElectricalCalculations.recommendedFuse(forCurrent: load.current) + ) + } + + @Test func syncFollowsTheBudgetInBothDirections() async throws { + let load = makeLoad(current: 5, length: 10, crossSection: 1.0) + + LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 3) + // 3 % needs 4.72mm² → 6.0mm² + #expect(load.crossSection == 6.0) + + LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 10) + // 10 % needs 1.42mm², but the 7.5A fuse still needs a cable rated for it → 1.5mm² + #expect(load.crossSection == 1.5) + } + + @Test func syncIsIdempotent() async throws { + let load = makeLoad(current: 10, length: 5, crossSection: 1.0) + #expect(LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 5) == 1) + #expect(LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 5) == 0) + } + + @Test func syncLeavesIncompleteLoadsAlone() async throws { + let noLength = makeLoad(current: 5, length: 0, crossSection: 0) + let noCurrent = makeLoad(current: 0, length: 5, crossSection: 0) + #expect(LoadCableSync.synchronize(loads: [noLength, noCurrent], maxVoltageDropPercent: 5) == 0) + #expect(noLength.crossSection == 0) + #expect(noCurrent.crossSection == 0) + } + + @Test func syncedSystemHasNothingLeftToReport() async throws { + let loads = [ + makeLoad(current: 5, length: 10, crossSection: 1.0), + makeLoad(current: 40, length: 0.5, crossSection: 1.5), + ] + LoadCableSync.synchronize(loads: loads, maxVoltageDropPercent: 5) + #expect(LoadConfigurationStatus.evaluate(loads: loads) == nil) + } + + @Test func onlyMissingInputsAreStillReported() async throws { + let loads = [ + makeLoad(current: 0, length: 0, crossSection: 0), + makeLoad(current: 5, length: 2, crossSection: 2.5), + ] + #expect(LoadConfigurationStatus.evaluate(loads: loads) == .missingDetails(count: 1)) + #expect(LoadConfigurationStatus.evaluate(loads: []) == nil) + } } diff --git a/CableUITestsScreenshot/SystemVoltageDropTargetUITests.swift b/CableUITestsScreenshot/SystemVoltageDropTargetUITests.swift new file mode 100644 index 0000000..b1f91bc --- /dev/null +++ b/CableUITestsScreenshot/SystemVoltageDropTargetUITests.swift @@ -0,0 +1,64 @@ +import XCTest + +/// Verifies that a system exposes its voltage-drop budget and that picking a tighter +/// target sticks, which is what drives cable sizing for that system. +final class SystemVoltageDropTargetUITests: XCTestCase { + + override func setUpWithError() throws { + try super.setUpWithError() + continueAfterFailure = false + XCUIDevice.shared.orientation = .portrait + } + + @MainActor + func testVoltageDropBudgetIsSelectableInSystemEditor() throws { + let app = launch(arguments: ["--uitest-reset-data", "--uitest-sample-data"]) + + openFirstSystem(in: app) + + let systemTitle = app.buttons["system-title-button"] + XCTAssertTrue(systemTitle.waitForExistence(timeout: 15)) + systemTitle.tap() + + let picker = app.segmentedControls["system-voltage-drop-picker"] + XCTAssertTrue(picker.waitForExistence(timeout: 15), "System editor must offer a voltage drop budget") + + let standard = picker.buttons["5 %"] + XCTAssertTrue(standard.waitForExistence(timeout: 5)) + XCTAssertTrue(standard.isSelected, "New systems start at the 5 % default") + + let critical = picker.buttons["3 %"] + XCTAssertTrue(critical.exists) + critical.tap() + XCTAssertTrue(critical.isSelected, "Picking 3 % must stick") + } + + // MARK: - Helpers + + private func launch(arguments: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = arguments + app.launch() + return app + } + + private func openFirstSystem(in app: XCUIApplication) { + let list: XCUIElement + if app.collectionViews["systems-list"].waitForExistence(timeout: 15) { + list = app.collectionViews["systems-list"] + } else { + list = app.collectionViews.firstMatch + } + XCTAssertTrue(list.waitForExistence(timeout: 15)) + + let firstCell = list.cells.element(boundBy: 0) + XCTAssertTrue(firstCell.waitForExistence(timeout: 10)) + + let cellButton = firstCell.buttons.firstMatch + if cellButton.exists { + cellButton.tap() + } else { + firstCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + } +}