Files
Cable/CableTests/CableTests.swift
Stefan Lange-Hegermann 38183f5282 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.
2026-08-18 10:59:16 +02:00

621 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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)
}
}