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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user