Calculator: size cables for ampacity and drop budget

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.
This commit is contained in:
2026-08-18 10:59:16 +02:00
parent 88e79d79bf
commit 38183f5282
20 changed files with 624 additions and 191 deletions

View File

@@ -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.";

View File

@@ -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,7 +39,8 @@ class CableCalculator: ObservableObject {
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem
unitSystem: unitSystem,
maxVoltageDropPercent: maxVoltageDropPercent
)
}
@@ -51,7 +53,8 @@ class CableCalculator: ObservableObject {
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem
unitSystem: unitSystem,
maxVoltageDropPercent: maxVoltageDropPercent
)
}
@@ -60,7 +63,8 @@ class CableCalculator: ObservableObject {
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem
unitSystem: unitSystem,
maxVoltageDropPercent: maxVoltageDropPercent
)
}
@@ -69,7 +73,8 @@ class CableCalculator: ObservableObject {
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
}
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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))
}
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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<String>, location: Binding<String>, iconName: Binding<String>, colorName: Binding<String>) {
/// 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<String>,
location: Binding<String>,
iconName: Binding<String>,
colorName: Binding<String>,
maxVoltageDropPercent: Binding<Double>
) {
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(
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"
@Previewable @State var dropTarget = 5.0
return SystemEditorView(systemName: $name, location: $location, iconName: $icon, colorName: $color)
return SystemEditorView(
systemName: $name,
location: $location,
iconName: $icon,
colorName: $color,
maxVoltageDropPercent: $dropTarget
)
}

View File

@@ -324,7 +324,8 @@ struct SystemsView: View {
name: systemName,
location: "",
iconName: resolvedIconName,
colorName: resolvedColorName
colorName: resolvedColorName,
maxVoltageDropPercent: unitSettings.voltageDropTargetPercent
)
modelContext.insert(newSystem)
return newSystem

View File

@@ -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
}
}

View File

@@ -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.";

View File

@@ -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.";

View File

@@ -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.";

View File

@@ -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.";

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}