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.
621 lines
26 KiB
Swift
621 lines
26 KiB
Swift
//
|
||
// CableTests.swift
|
||
// CableTests
|
||
//
|
||
// Created by Stefan Lange-Hegermann on 11.09.25.
|
||
//
|
||
|
||
import Testing
|
||
@testable import Cable
|
||
|
||
struct CableTests {
|
||
|
||
// MARK: - Core Formula Verification
|
||
|
||
/// Formula: A = (2 × I × L × ρ) / V_drop
|
||
/// With ρ = 0.017 Ω·mm²/m, max voltage drop = 5%
|
||
@Test func metricWireSizingUsesNearestStandardSize() async throws {
|
||
// 10m, 5A, 12V → minCS = (2×5×10×0.017)/(12×0.05) = 2.833mm² → rounds up to 4.0mm²
|
||
let crossSection = ElectricalCalculations.recommendedCrossSection(
|
||
length: 10,
|
||
current: 5,
|
||
voltage: 12,
|
||
unitSystem: .metric
|
||
)
|
||
#expect(crossSection == 4.0)
|
||
|
||
// V_drop = (2×5×10×0.017)/4.0 = 0.425V
|
||
let voltageDrop = ElectricalCalculations.voltageDrop(
|
||
length: 10,
|
||
current: 5,
|
||
voltage: 12,
|
||
unitSystem: .metric
|
||
)
|
||
#expect(abs(voltageDrop - 0.425) < 0.001)
|
||
|
||
// 0.425/12 × 100 = 3.5417%
|
||
let dropPercentage = ElectricalCalculations.voltageDropPercentage(
|
||
length: 10,
|
||
current: 5,
|
||
voltage: 12,
|
||
unitSystem: .metric
|
||
)
|
||
#expect(abs(dropPercentage - 3.5417) < 0.001)
|
||
|
||
// P_loss = I × V_drop = 5 × 0.425 = 2.125W
|
||
let powerLoss = ElectricalCalculations.powerLoss(
|
||
length: 10,
|
||
current: 5,
|
||
voltage: 12,
|
||
unitSystem: .metric
|
||
)
|
||
#expect(abs(powerLoss - 2.125) < 0.001)
|
||
}
|
||
|
||
/// Imperial test: length is always in meters (25 ft = 7.62 m), unitSystem controls AWG output
|
||
@Test func imperialWireSizingMatchesExpectedGauge() async throws {
|
||
let lengthInMeters = 25.0 * 0.3048 // 25 ft = 7.62 m
|
||
let awg = ElectricalCalculations.recommendedCrossSection(
|
||
length: lengthInMeters,
|
||
current: 15,
|
||
voltage: 120,
|
||
unitSystem: .imperial
|
||
)
|
||
// minCS = (2×15×7.62×0.017)/(120×0.05) = 0.648mm² → AWG 18 (0.823mm²)
|
||
#expect(awg == 18.0)
|
||
|
||
let voltageDrop = ElectricalCalculations.voltageDrop(
|
||
length: lengthInMeters,
|
||
current: 15,
|
||
voltage: 120,
|
||
unitSystem: .imperial
|
||
)
|
||
// (2×15×7.62×0.017)/0.823 = 4.722V
|
||
#expect(abs(voltageDrop - 4.722) < 0.01)
|
||
|
||
let dropPercentage = ElectricalCalculations.voltageDropPercentage(
|
||
length: lengthInMeters,
|
||
current: 15,
|
||
voltage: 120,
|
||
unitSystem: .imperial
|
||
)
|
||
// 4.722/120 × 100 = 3.935%
|
||
#expect(abs(dropPercentage - 3.935) < 0.01)
|
||
|
||
let powerLoss = ElectricalCalculations.powerLoss(
|
||
length: lengthInMeters,
|
||
current: 15,
|
||
voltage: 120,
|
||
unitSystem: .imperial
|
||
)
|
||
// 15 × 4.722 = 70.83W
|
||
#expect(abs(powerLoss - 70.83) < 0.05)
|
||
}
|
||
|
||
// MARK: - Fuse Sizing
|
||
|
||
@Test func recommendedFuseRoundsUpToNearestStandardSize() async throws {
|
||
// 7.2A × 1.25 = 9.0 → next fuse ≥ 9 = 10A
|
||
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 7.2) == 10.0)
|
||
// 59A × 1.25 = 73.75 → ceil = 74 → next fuse ≥ 74 = 80A
|
||
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 59.0) == 80.0)
|
||
}
|
||
|
||
@Test func fuseAt125PercentOfCurrent() async throws {
|
||
// 10A × 1.25 = 12.5 → ceil = 13 → next fuse = 15A
|
||
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 10.0) == 15.0)
|
||
// 20A × 1.25 = 25 → next fuse = 25A
|
||
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 20.0) == 25.0)
|
||
// 1A × 1.25 = 1.25 → ceil = 2 → next fuse = 2A
|
||
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 1.0) == 2.0)
|
||
// 4A × 1.25 = 5 → next fuse = 5A
|
||
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 4.0) == 5.0)
|
||
// 100A × 1.25 = 125 → next fuse = 125A
|
||
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 100.0) == 125.0)
|
||
}
|
||
|
||
@Test func fuseSelectsHalfAmpereSizes() async throws {
|
||
// 5A × 1.25 = 6.25 → ceil = 7 → next fuse ≥ 7 = 7.5A
|
||
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 5.0) == 7.5)
|
||
// 6A × 1.25 = 7.5 → ceil = 8 → next fuse ≥ 8 = 10A
|
||
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 6.0) == 10.0)
|
||
}
|
||
|
||
@Test func fuseForZeroCurrent() async throws {
|
||
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 0) == 1.0)
|
||
}
|
||
|
||
// MARK: - Metric Cross-Section Boundaries
|
||
|
||
@Test func metricCrossSectionSelectsSmallestAdequateSize() async throws {
|
||
// Very small load: 1m, 0.5A, 12V
|
||
// minCS = (2×0.5×1×0.017)/(12×0.05) = 0.0283mm² → rounds up to 0.75mm² (smallest standard)
|
||
let cs = ElectricalCalculations.recommendedCrossSection(
|
||
length: 1, current: 0.5, voltage: 12, unitSystem: .metric
|
||
)
|
||
#expect(cs == 0.75)
|
||
}
|
||
|
||
@Test func metricCrossSectionForHighCurrent() async throws {
|
||
// 15m, 80A, 12V
|
||
// minCS = (2×80×15×0.017)/(12×0.05) = 40.8/0.6 = 68mm² → rounds up to 70mm²
|
||
let cs = ElectricalCalculations.recommendedCrossSection(
|
||
length: 15, current: 80, voltage: 12, unitSystem: .metric
|
||
)
|
||
#expect(cs == 70.0)
|
||
}
|
||
|
||
@Test func metricCrossSectionFor24VSystem() async throws {
|
||
// 10m, 20A, 24V
|
||
// minCS = (2×20×10×0.017)/(24×0.05) = 6.8/1.2 = 5.667mm² → rounds up to 6.0mm²
|
||
let cs = ElectricalCalculations.recommendedCrossSection(
|
||
length: 10, current: 20, voltage: 24, unitSystem: .metric
|
||
)
|
||
#expect(cs == 6.0)
|
||
}
|
||
|
||
@Test func metricCrossSectionFor48VSystem() async throws {
|
||
// 10m, 20A, 48V
|
||
// minCS = (2×20×10×0.017)/(48×0.05) = 6.8/2.4 = 2.833mm² → rounds up to 4.0mm²
|
||
let cs = ElectricalCalculations.recommendedCrossSection(
|
||
length: 10, current: 20, voltage: 48, unitSystem: .metric
|
||
)
|
||
#expect(cs == 4.0)
|
||
}
|
||
|
||
// MARK: - Imperial AWG Selection (including 1/0 through 4/0)
|
||
|
||
@Test func imperialAWGLargeGauges() async throws {
|
||
// Very high current should select large AWG sizes (represented as negative ints)
|
||
// 10m, 100A, 12V → minCS = (2×100×10×0.017)/(12×0.05) = 34/0.6 = 56.67mm²
|
||
// AWG: first awgCS ≥ 56.67 → 67.4 = 2/0 (represented as -2)
|
||
let awg = ElectricalCalculations.recommendedCrossSection(
|
||
length: 10, current: 100, voltage: 12, unitSystem: .imperial
|
||
)
|
||
#expect(awg == -2.0)
|
||
|
||
// Verify voltage drop uses the correct cross-section (67.4mm² for 2/0)
|
||
let drop = ElectricalCalculations.voltageDrop(
|
||
length: 10, current: 100, voltage: 12,
|
||
unitSystem: .imperial, crossSection: awg
|
||
)
|
||
// (2×100×10×0.017)/67.4 = 34/67.4 = 0.5045V
|
||
#expect(abs(drop - 0.5045) < 0.01)
|
||
}
|
||
|
||
@Test func imperialAWG4over0ForExtremeCurrent() async throws {
|
||
// 5m, 200A, 12V → minCS = (2×200×5×0.017)/(12×0.05) = 34/0.6 = 56.67mm²
|
||
// Wait: (2×200×5×0.017)/0.6 = 34/0.6 = 56.67 → 67.4 (2/0)
|
||
// 10m, 200A, 12V → minCS = (2×200×10×0.017)/(12×0.05) = 68/0.6 = 113.33mm²
|
||
// AWG: first awgCS ≥ 113.33 → none! → returns last AWG = -4 (4/0, 107mm²)
|
||
let awg = ElectricalCalculations.recommendedCrossSection(
|
||
length: 10, current: 200, voltage: 12, unitSystem: .imperial
|
||
)
|
||
#expect(awg == -4.0)
|
||
}
|
||
|
||
@Test func formatAWGDisplaysCorrectNotation() async throws {
|
||
#expect(ElectricalCalculations.formatAWG(14) == "14")
|
||
#expect(ElectricalCalculations.formatAWG(10) == "10")
|
||
#expect(ElectricalCalculations.formatAWG(1) == "1")
|
||
#expect(ElectricalCalculations.formatAWG(-1) == "1/0")
|
||
#expect(ElectricalCalculations.formatAWG(-2) == "2/0")
|
||
#expect(ElectricalCalculations.formatAWG(-3) == "3/0")
|
||
#expect(ElectricalCalculations.formatAWG(-4) == "4/0")
|
||
}
|
||
|
||
@Test func imperialAWGForBoatLoad() async throws {
|
||
// Typical boat scenario: 5m (≈16ft), 10A, 12V
|
||
// minCS = (2×10×5×0.017)/(12×0.05) = 1.7/0.6 = 2.833mm²
|
||
// AWG: first >= 2.833 → 3.31mm² = 12 AWG
|
||
let awg = ElectricalCalculations.recommendedCrossSection(
|
||
length: 5, current: 10, voltage: 12, unitSystem: .imperial
|
||
)
|
||
#expect(awg == 12.0)
|
||
}
|
||
|
||
@Test func imperialAWGForHighCurrentLoad() async throws {
|
||
// 3m, 50A, 12V
|
||
// minCS = (2×50×3×0.017)/(12×0.05) = 5.1/0.6 = 8.5mm²
|
||
// AWG: first >= 8.5 → 13.3mm² = 6 AWG
|
||
let awg = ElectricalCalculations.recommendedCrossSection(
|
||
length: 3, current: 50, voltage: 12, unitSystem: .imperial
|
||
)
|
||
#expect(awg == 6.0)
|
||
}
|
||
|
||
// MARK: - Voltage Drop with Explicit Cross-Section
|
||
|
||
@Test func voltageDropWithExplicitCrossSection_metric() async throws {
|
||
// 5m, 10A, 12V, 2.5mm²
|
||
// V_drop = (2×10×5×0.017)/2.5 = 1.7/2.5 = 0.68V
|
||
let drop = ElectricalCalculations.voltageDrop(
|
||
length: 5, current: 10, voltage: 12,
|
||
unitSystem: .metric, crossSection: 2.5
|
||
)
|
||
#expect(abs(drop - 0.68) < 0.001)
|
||
|
||
let pct = ElectricalCalculations.voltageDropPercentage(
|
||
length: 5, current: 10, voltage: 12,
|
||
unitSystem: .metric, crossSection: 2.5
|
||
)
|
||
// 0.68/12 × 100 = 5.667%
|
||
#expect(abs(pct - 5.667) < 0.01)
|
||
}
|
||
|
||
@Test func voltageDropWithExplicitCrossSection_imperial() async throws {
|
||
// 3m, 15A, 12V, AWG 10 (= 5.26mm²)
|
||
// V_drop = (2×15×3×0.017)/5.26 = 1.53/5.26 = 0.2909V
|
||
let drop = ElectricalCalculations.voltageDrop(
|
||
length: 3, current: 15, voltage: 12,
|
||
unitSystem: .imperial, crossSection: 10 // AWG 10
|
||
)
|
||
#expect(abs(drop - 0.2909) < 0.01)
|
||
}
|
||
|
||
// MARK: - Power Loss Verification
|
||
|
||
@Test func powerLossEqualsCurrentTimesVoltageDrop() async throws {
|
||
let length = 8.0
|
||
let current = 12.0
|
||
let voltage = 24.0
|
||
|
||
let drop = ElectricalCalculations.voltageDrop(
|
||
length: length, current: current, voltage: voltage, unitSystem: .metric
|
||
)
|
||
let loss = ElectricalCalculations.powerLoss(
|
||
length: length, current: current, voltage: voltage, unitSystem: .metric
|
||
)
|
||
// P_loss = I × V_drop
|
||
#expect(abs(loss - current * drop) < 0.001)
|
||
}
|
||
|
||
// MARK: - Edge Cases
|
||
|
||
@Test func zeroLengthProducesZeroDrop() async throws {
|
||
let drop = ElectricalCalculations.voltageDrop(
|
||
length: 0, current: 10, voltage: 12, unitSystem: .metric
|
||
)
|
||
#expect(drop == 0)
|
||
|
||
let loss = ElectricalCalculations.powerLoss(
|
||
length: 0, current: 10, voltage: 12, unitSystem: .metric
|
||
)
|
||
#expect(loss == 0)
|
||
}
|
||
|
||
@Test func zeroCurrentProducesZeroDrop() async throws {
|
||
let drop = ElectricalCalculations.voltageDrop(
|
||
length: 10, current: 0, voltage: 12, unitSystem: .metric
|
||
)
|
||
#expect(drop == 0)
|
||
}
|
||
|
||
@Test func zeroVoltageReturnsZeroPercentage() async throws {
|
||
let pct = ElectricalCalculations.voltageDropPercentage(
|
||
length: 10, current: 5, voltage: 0, unitSystem: .metric
|
||
)
|
||
#expect(pct == 0)
|
||
}
|
||
|
||
@Test func zeroVoltageReturnsZeroCrossSection() async throws {
|
||
// With 0V, maxVoltageDrop = 0, guardAgainstZero returns 0 → smallest standard
|
||
let cs = ElectricalCalculations.recommendedCrossSection(
|
||
length: 10, current: 5, voltage: 0, unitSystem: .metric
|
||
)
|
||
#expect(cs == 0.75)
|
||
}
|
||
|
||
// MARK: - Metric and Imperial Consistency
|
||
|
||
@Test func metricAndImperialGiveSamePhysicalVoltageDrop() async throws {
|
||
// Same physical setup: 10m cable, 15A, 12V
|
||
// Metric: recommended cross-section in mm²
|
||
// Imperial: recommended cross-section in AWG
|
||
let lengthMeters = 10.0
|
||
|
||
let metricCS = ElectricalCalculations.recommendedCrossSection(
|
||
length: lengthMeters, current: 15, voltage: 12, unitSystem: .metric
|
||
)
|
||
let imperialAWG = ElectricalCalculations.recommendedCrossSection(
|
||
length: lengthMeters, current: 15, voltage: 12, unitSystem: .imperial
|
||
)
|
||
|
||
let metricDrop = ElectricalCalculations.voltageDrop(
|
||
length: lengthMeters, current: 15, voltage: 12,
|
||
unitSystem: .metric, crossSection: metricCS
|
||
)
|
||
let imperialDrop = ElectricalCalculations.voltageDrop(
|
||
length: lengthMeters, current: 15, voltage: 12,
|
||
unitSystem: .imperial, crossSection: imperialAWG
|
||
)
|
||
|
||
// Both should be within 5% voltage drop constraint
|
||
#expect(metricDrop / 12 <= 0.05)
|
||
#expect(imperialDrop / 12 <= 0.05)
|
||
|
||
// Both drops should be positive
|
||
#expect(metricDrop > 0)
|
||
#expect(imperialDrop > 0)
|
||
}
|
||
|
||
// MARK: - Unit Convention: Length Always in Meters
|
||
|
||
@Test func lengthParameterIsAlwaysMeters() async throws {
|
||
// Passing the same meter value with both unit systems should give
|
||
// the same underlying calculation, only differing in output format.
|
||
let lengthMeters = 7.62 // = 25 feet
|
||
|
||
let metricMinCS = ElectricalCalculations.recommendedCrossSection(
|
||
length: lengthMeters, current: 15, voltage: 120, unitSystem: .metric
|
||
)
|
||
let imperialAWG = ElectricalCalculations.recommendedCrossSection(
|
||
length: lengthMeters, current: 15, voltage: 120, unitSystem: .imperial
|
||
)
|
||
|
||
// Minimum raw cross-section: (2×15×7.62×0.017)/(120×0.05) = 0.648mm²
|
||
// 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)
|
||
}
|
||
|
||
// MARK: - Voltage Drop Constraint
|
||
|
||
@Test func recommendedCrossSectionKeepsDropBelow5Percent() async throws {
|
||
// Test several scenarios to verify the 5% constraint
|
||
let scenarios: [(length: Double, current: Double, voltage: Double)] = [
|
||
(1, 1, 12), // minimal
|
||
(5, 10, 12), // moderate boat load
|
||
(10, 20, 24), // 24V system
|
||
(15, 50, 12), // high current
|
||
(3, 100, 48), // very high current, 48V
|
||
(20, 5, 12), // long run, low current
|
||
]
|
||
|
||
for s in scenarios {
|
||
let cs = ElectricalCalculations.recommendedCrossSection(
|
||
length: s.length, current: s.current, voltage: s.voltage, unitSystem: .metric
|
||
)
|
||
let dropPct = ElectricalCalculations.voltageDropPercentage(
|
||
length: s.length, current: s.current, voltage: s.voltage,
|
||
unitSystem: .metric, crossSection: cs
|
||
)
|
||
#expect(dropPct <= 5.0, "Drop \(dropPct)% exceeds 5% for \(s.length)m, \(s.current)A, \(s.voltage)V with \(cs)mm²")
|
||
}
|
||
}
|
||
|
||
// MARK: - Formula Cross-Check: V_drop = I²R path
|
||
|
||
@Test func voltageDropMatchesOhmsLaw() async throws {
|
||
let length = 6.0
|
||
let current = 8.0
|
||
let crossSection = 4.0 // mm²
|
||
let resistivity = 0.017
|
||
|
||
// R = (2 × L × ρ) / A = (2 × 6 × 0.017) / 4 = 0.051 Ω
|
||
let resistance = (2 * length * resistivity) / crossSection
|
||
// V = I × R = 8 × 0.051 = 0.408V
|
||
let expectedDrop = current * resistance
|
||
// P = I² × R = 64 × 0.051 = 3.264W
|
||
let expectedPowerLoss = current * current * resistance
|
||
|
||
let actualDrop = ElectricalCalculations.voltageDrop(
|
||
length: length, current: current, voltage: 12,
|
||
unitSystem: .metric, crossSection: crossSection
|
||
)
|
||
let actualLoss = ElectricalCalculations.powerLoss(
|
||
length: length, current: current, voltage: 12,
|
||
unitSystem: .metric, crossSection: crossSection
|
||
)
|
||
|
||
#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)
|
||
}
|
||
}
|