From 03652e73582f6df52a0d7bdc97d7e7fcb095377d Mon Sep 17 00:00:00 2001 From: Jairon Terrero Date: Wed, 10 Jan 2024 15:29:29 -0700 Subject: [PATCH] feature: Fractional Exponents --- Sources/Units/Measurement/Measurement.swift | 4 +- Sources/Units/Registry.swift | 6 +- Sources/Units/Unit/DefinedUnit.swift | 4 +- Sources/Units/Unit/Equations.swift | 12 +- Sources/Units/Unit/Fraction.swift | 178 ++++++++++++++++++++ Sources/Units/Unit/Unit.swift | 20 +-- Tests/UnitsTests/FractionTests.swift | 162 ++++++++++++++++++ Tests/UnitsTests/UnitTests.swift | 46 +++++ 8 files changed, 409 insertions(+), 23 deletions(-) create mode 100644 Sources/Units/Unit/Fraction.swift create mode 100644 Tests/UnitsTests/FractionTests.swift diff --git a/Sources/Units/Measurement/Measurement.swift b/Sources/Units/Measurement/Measurement.swift index 8589f43..027594d 100644 --- a/Sources/Units/Measurement/Measurement.swift +++ b/Sources/Units/Measurement/Measurement.swift @@ -136,9 +136,9 @@ public struct Measurement: Equatable, Codable { /// Exponentiate the measurement. This is equavalent to multiple `*` operations. /// - Parameter raiseTo: The exponent to raise the measurement to /// - Returns: A new measurement with an exponentiated scalar value and an exponentiated unit of measure - public func pow(_ raiseTo: Int) -> Measurement { + public func pow(_ raiseTo: Fraction) -> Measurement { return Measurement( - value: Foundation.pow(value, Double(raiseTo)), + value: Foundation.pow(value, raiseTo.asDouble), unit: unit.pow(raiseTo) ) } diff --git a/Sources/Units/Registry.swift b/Sources/Units/Registry.swift index efbcea5..021ed7e 100644 --- a/Sources/Units/Registry.swift +++ b/Sources/Units/Registry.swift @@ -29,10 +29,10 @@ internal class Registry { /// Returns a list of defined units and their exponents, given a composite unit symbol. It is expected that the caller has /// verified that this is a composite unit. - internal func compositeUnitsFromSymbol(symbol: String) throws -> [DefinedUnit: Int] { + internal func compositeUnitsFromSymbol(symbol: String) throws -> [DefinedUnit: Fraction] { let symbolsAndExponents = try deserializeSymbolicEquation(symbol) - var compositeUnits = [DefinedUnit: Int]() + var compositeUnits = [DefinedUnit: Fraction]() for (definedUnitSymbol, exponent) in symbolsAndExponents { guard exponent != 0 else { continue @@ -70,7 +70,7 @@ internal class Registry { internal func addUnit( name: String, symbol: String, - dimension: [Quantity: Int], + dimension: [Quantity: Fraction], coefficient: Double = 1, constant: Double = 0 ) throws { diff --git a/Sources/Units/Unit/DefinedUnit.swift b/Sources/Units/Unit/DefinedUnit.swift index 3805d6c..18726b3 100644 --- a/Sources/Units/Unit/DefinedUnit.swift +++ b/Sources/Units/Unit/DefinedUnit.swift @@ -2,11 +2,11 @@ struct DefinedUnit: Hashable, Sendable { let name: String let symbol: String - let dimension: [Quantity: Int] + let dimension: [Quantity: Fraction] let coefficient: Double let constant: Double - init(name: String, symbol: String, dimension: [Quantity: Int], coefficient: Double = 1, constant: Double = 0) throws { + init(name: String, symbol: String, dimension: [Quantity: Fraction], coefficient: Double = 1, constant: Double = 0) throws { guard !symbol.isEmpty else { throw UnitError.invalidSymbol(message: "Symbol cannot be empty") } diff --git a/Sources/Units/Unit/Equations.swift b/Sources/Units/Unit/Equations.swift index f2295de..dd77b29 100644 --- a/Sources/Units/Unit/Equations.swift +++ b/Sources/Units/Unit/Equations.swift @@ -14,7 +14,7 @@ /// - spaceAroundOperators: Whether to include space characters before and after multiplication and division characters. /// - Returns: A string that represents the equation of the object symbols and their respective exponentiation. func serializeSymbolicEquation( - of dict: [T: Int], + of dict: [T: Fraction], symbolPath: KeyPath, spaceAroundOperators: Bool = false ) -> String { @@ -76,7 +76,7 @@ func serializeSymbolicEquation( } let symbol = object[keyPath: symbolPath] var expStr = "" - if abs(exp) > 1 { + if abs(exp) != 0, abs(exp) != 1 { expStr = "\(expSymbol)\(abs(exp))" } @@ -93,19 +93,19 @@ func serializeSymbolicEquation( /// - Returns: A dictionary containing object symbols and exponents func deserializeSymbolicEquation( _ equation: String -) throws -> [String: Int] { +) throws -> [String: Fraction] { let expSymbol = OperatorSymbols.exp.rawValue let multSymbol = OperatorSymbols.mult.rawValue let divSymbol = OperatorSymbols.div.rawValue - var result = [String: Int]() + var result = [String: Fraction]() for multChunks in equation.split(separator: multSymbol, omittingEmptySubsequences: false) { for (index, divChunks) in multChunks.split(separator: divSymbol, omittingEmptySubsequences: false).enumerated() { let symbolChunks = divChunks.split(separator: expSymbol, omittingEmptySubsequences: false) let subSymbol = String(symbolChunks[0]).trimmingCharacters(in: .whitespaces) - var exp = 1 + var exp: Fraction = 1 if symbolChunks.count == 2 { - guard let expInt = Int(String(symbolChunks[1])) else { + guard let expInt = Fraction(String(symbolChunks[1])) else { throw UnitError.invalidSymbol(message: "Symbol '^' must be followed by an integer: \(equation)") } exp = expInt diff --git a/Sources/Units/Unit/Fraction.swift b/Sources/Units/Unit/Fraction.swift new file mode 100644 index 0000000..e5bfd7f --- /dev/null +++ b/Sources/Units/Unit/Fraction.swift @@ -0,0 +1,178 @@ + +/// Represents a reduced fractional number. +/// An invariant exists such that it is not possible to create a ``Fraction`` +/// that is not represented in its most reduced form. +public struct Fraction: Hashable, Equatable, Sendable { + public let numerator: Int + public let denominator: Int + + /// Combines the provided `numerator` and `denominator` into a reduced ``Fraction``. + /// - Warning: Attempts to create a ``Fraction`` with a zero denominator will fatally error. + public init(numerator: Int, denominator: Int) { + let gcd = Self.gcd(numerator, denominator) + self.numerator = numerator / gcd + self.denominator = denominator / gcd + } + + public var positive: Bool { + switch (numerator, denominator) { + // 0/0 is not positive in this logic + case let (n, d) where n >= 0 && d > 0: true + + // Seems like this case can't happen because + // all Fractions are reduced. + case let (n, d) where n < 0 && d < 0: true + + default: false + } + } +} + +private extension Fraction { + static func gcd(_ a: Int, _ b: Int) -> Int { + // See: https://en.wikipedia.org/wiki/Euclidean_algorithm + var latestRemainder = max(a, b) + var previousRemainder = min(a, b) + + while latestRemainder != 0 { + let tmp = latestRemainder + latestRemainder = previousRemainder % latestRemainder + previousRemainder = tmp + } + return previousRemainder + } +} + + +extension Fraction { + public static func * (lhs: Self, rhs: Self) -> Self { + Self(numerator: lhs.numerator * rhs.numerator, denominator: lhs.denominator * rhs.denominator) + } + + public static func / (lhs: Self, rhs: Self) -> Self { + Self(numerator: lhs.numerator * rhs.denominator, denominator: lhs.denominator * rhs.numerator) + } + + public static func + (lhs: Self, rhs: Self) -> Self { + Self(numerator: (lhs.numerator * rhs.denominator) + (rhs.numerator * lhs.denominator), denominator: lhs.denominator * rhs.denominator) + } + + public static func - (lhs: Self, rhs: Self) -> Self { + Self(numerator: (lhs.numerator * rhs.denominator) - (rhs.numerator * lhs.denominator), denominator: lhs.denominator * rhs.denominator) + } +} +extension Fraction { + public static func * (lhs: Self, rhs: Int) -> Self { + lhs * Self(integerLiteral: rhs) + } + + public static func / (lhs: Self, rhs: Int) -> Self { + lhs / Self(integerLiteral: rhs) + } + + public static func * (lhs: Int, rhs: Self) -> Self { + Self(integerLiteral: lhs) * rhs + } + + public static func / (lhs: Int, rhs: Self) -> Self { + Self(integerLiteral: lhs) / rhs + } + + public static func + (lhs: Self, rhs: Int) -> Self { + lhs + Self(integerLiteral: rhs) + } + + public static func - (lhs: Self, rhs: Int) -> Self { + lhs - Self(integerLiteral: rhs) + } + + public static func + (lhs: Int, rhs: Self) -> Self { + Self(integerLiteral: lhs) + rhs + } + + public static func - (lhs: Int, rhs: Self) -> Self { + Self(integerLiteral: lhs) - rhs + } +} + +extension Fraction: ExpressibleByIntegerLiteral { + public typealias IntegerLiteralType = Int + + public init(integerLiteral value: Int) { + self.init(numerator: value, denominator: 1) + } +} + +extension Fraction: SignedNumeric { + + public init?(exactly source: T) where T : BinaryInteger { + self.init(integerLiteral: Int(source)) + } + + public static func *= (lhs: inout Fraction, rhs: Fraction) { + lhs = lhs * rhs + } + + public var magnitude: Fraction { + Self(numerator: abs(numerator), denominator: abs(denominator)) + } + + public typealias Magnitude = Self + +} + +extension Fraction { + public var asDouble: Double { + Double(numerator) / Double(denominator) + } +} + +extension Fraction: Comparable { + public static func < (lhs: Fraction, rhs: Fraction) -> Bool { + lhs.numerator * rhs.denominator < rhs.numerator * lhs.denominator + } +} + +extension Fraction: LosslessStringConvertible { + /// The format for string conversion is: `(|)` or `` + public init?(_ description: String) { + if + description.first == "(", + description.last == ")" + { + let parts = description.dropFirst().dropLast().split(separator: "|").compactMap({ Int(String($0)) }) + guard + parts.count == 2, + let numerator = parts.first, + let denominator = parts.last + else { + return nil + } + self.init(numerator: numerator, denominator: denominator) + } else if let number = Int(description) { + self.init(integerLiteral: number) + } else { + return nil + } + } + + public var description: String { + if denominator == 1 { + "\(!positive && numerator != 0 ? "-" : "")\(abs(numerator))" + } else { + "(\(positive ? "" : "-")\(abs(numerator))|\(abs(denominator)))" + } + } +} + +extension SignedInteger { + func over(_ denominator: T) -> Fraction { + Fraction(numerator: Int(self), denominator: Int(denominator)) + } +} + +extension Int { + public static func |(_ lhs: Self, _ rhs: Self) -> Fraction { + Fraction(numerator: lhs, denominator: rhs) + } +} diff --git a/Sources/Units/Unit/Unit.swift b/Sources/Units/Unit/Unit.swift index dab3581..fba0029 100644 --- a/Sources/Units/Unit/Unit.swift +++ b/Sources/Units/Unit/Unit.swift @@ -55,7 +55,7 @@ public struct Unit { /// Create a new from the sub-unit dictionary. /// - Parameter subUnits: A dictionary of defined units and exponents. If this dictionary has only a single unit with an exponent of one, /// we return that defined unit directly. - internal init(composedOf subUnits: [DefinedUnit: Int]) { + internal init(composedOf subUnits: [DefinedUnit: Fraction]) { if subUnits.count == 1, let subUnit = subUnits.first, subUnit.value == 1 { type = .defined(subUnit.key) } else { @@ -88,7 +88,7 @@ public struct Unit { public static func define( name: String, symbol: String, - dimension: [Quantity: Int], + dimension: [Quantity: Fraction], coefficient: Double = 1, constant: Double = 0 ) throws -> Unit { @@ -123,7 +123,7 @@ public struct Unit { public static func register( name: String, symbol: String, - dimension: [Quantity: Int], + dimension: [Quantity: Fraction], coefficient: Double = 1, constant: Double = 0 ) throws -> Unit { @@ -144,14 +144,14 @@ public struct Unit { } /// The dimension of the unit in terms of base quanties - public var dimension: [Quantity: Int] { + public var dimension: [Quantity: Fraction] { switch type { case .none: return [:] case let .defined(definition): return definition.dimension case let .composite(subUnits): - var dimensions: [Quantity: Int] = [:] + var dimensions: [Quantity: Fraction] = [:] for (subUnit, exp) in subUnits { let subDimensions = subUnit.dimension.mapValues { value in exp * value @@ -259,7 +259,7 @@ public struct Unit { /// Exponentiate the unit. This is equavalent to multiple `*` operations. /// - Parameter raiseTo: The exponent to raise the unit to /// - Returns: A new unit modeling the original raised to the provided power - public func pow(_ raiseTo: Int) -> Unit { + public func pow(_ raiseTo: Fraction) -> Unit { switch type { case .none: return .none @@ -300,7 +300,7 @@ public struct Unit { guard subUnit.constant == 0 else { // subUnit must not have constant throw UnitError.invalidCompositeUnit(message: "Nonlinear unit prevents conversion: \(subUnit)") } - totalCoefficient *= Foundation.pow(subUnit.coefficient, Double(exponent)) + totalCoefficient *= Foundation.pow(subUnit.coefficient, exponent.asDouble) } return number * totalCoefficient } @@ -324,7 +324,7 @@ public struct Unit { guard subUnit.constant == 0 else { // subUnit must not have constant throw UnitError.invalidCompositeUnit(message: "Nonlinear unit prevents conversion: \(subUnit)") } - totalCoefficient *= Foundation.pow(subUnit.coefficient, Double(exponent)) + totalCoefficient *= Foundation.pow(subUnit.coefficient, exponent.asDouble) } return number / totalCoefficient } @@ -334,7 +334,7 @@ public struct Unit { /// Returns a dictionary that represents the unique defined units and their exponents. For a /// composite unit, this is simply the `subUnits`, but for a defined unit, this is `[self: 1]` - private var subUnits: [DefinedUnit: Int] { + private var subUnits: [DefinedUnit: Fraction] { switch type { case .none: return [:] @@ -349,7 +349,7 @@ public struct Unit { private enum UnitType: Sendable { case none case defined(DefinedUnit) - case composite([DefinedUnit: Int]) + case composite([DefinedUnit: Fraction]) } } diff --git a/Tests/UnitsTests/FractionTests.swift b/Tests/UnitsTests/FractionTests.swift new file mode 100644 index 0000000..9f86068 --- /dev/null +++ b/Tests/UnitsTests/FractionTests.swift @@ -0,0 +1,162 @@ +import Units +import XCTest + + + +final class FractionTests: XCTestCase { + + /// It should not be possible to create a non-reduced fraction + func testReductionInvariant() { + let testCases: [(fraction: Fraction, expectedNumerator: Int, expectedDenominator: Int)] = [ + ((5|10), 1, 2), + ((3|7), 3, 7), + ((21|7), 3, 1), + ((3|21), 1, 7), + ((0|5), 0, 1), + ] + for (fraction, expectedNumerator, expectedDenominator) in testCases { + XCTAssertEqual(fraction.numerator, expectedNumerator) + XCTAssertEqual(fraction.denominator, expectedDenominator) + } + } + + func testPositive() { + XCTAssertTrue((3 | 5).positive) + XCTAssertTrue((-4 | -16).positive) + XCTAssertFalse((-17 | 42).positive) + XCTAssertFalse((1 | -111).positive) + + XCTAssertTrue((0 | 1).positive) + XCTAssertTrue((-0 | -1).positive) + XCTAssertTrue((0 | -1).positive) + XCTAssertTrue((-0 | 1).positive) + } + + func testMultiplication() { + let fractionFractionCases: [(lhs: Fraction, rhs: Fraction, expected: Fraction)] = [ + ((1|2), (3|5), 3|10), + ((1|1), (17|11), 17|11), + ((4|2), (3 | -5), -12|10), + ((4|2), (3 | -5), -6|5), + ] + for (lhs, rhs, expected) in fractionFractionCases { + XCTAssertEqual(lhs * rhs, expected) + } + + let fractionIntCases: [(lhs: Fraction, rhs: Int, expected: Fraction)] = [ + ((1|2), 10, 10|2), + ((1|2), 10, 5), + ((1|1), 1, 1), + ((171|24), 1, 171|24), + ((4|2), -1, -2), + ((4|2), 15, 60|2), + ] + for (lhs, rhs, expected) in fractionIntCases { + XCTAssertEqual(lhs * rhs, expected) + XCTAssertEqual(rhs * lhs, expected) + } + } + + func testDivision() { + let fractionFractionCases: [(lhs: Fraction, rhs: Fraction, expected: Fraction)] = [ + ((1|2), (3|5), 5|6), + ((1|1), (17|11), 11|17), + ((4|2), (3 | -5), -20|6), + ((4|2), (3 | -5), -10|3), + ] + for (lhs, rhs, expected) in fractionFractionCases { + XCTAssertEqual(lhs / rhs, expected) + } + + let fractionIntCases: [(lhs: Fraction, rhs: Int, expected: Fraction)] = [ + ((1|2), 10, 1|20), + ((1|1), 1, 1), + ((171|24), 1, 171|24), + ((4|2), -1, -2), + ((4|2), 15, 4|30), + ] + for (lhs, rhs, expected) in fractionIntCases { + XCTAssertEqual(lhs / rhs, expected) + XCTAssertEqual(rhs / lhs, 1/expected) + } + } + + func testAddition() { + let fractionFractionCases: [(lhs: Fraction, rhs: Fraction, expected: Fraction)] = [ + ((1|2), (3|5), 11|10), + ((1|1), (17|11), 28|11), + ((4|2), (3 | -5), 14|10), + ((4|2), (3 | -5), 7|5), + ] + for (lhs, rhs, expected) in fractionFractionCases { + XCTAssertEqual(lhs + rhs, expected) + } + + let fractionIntCases: [(lhs: Fraction, rhs: Int, expected: Fraction)] = [ + ((1|2), 10, 21|2), + ((3|2), 10, 23|2), + ((1|1), 1, 2), + ((171|24), 1, 65|8), + ((4|2), -1, 1), + ((4|2), 15, 17), + ] + for (lhs, rhs, expected) in fractionIntCases { + XCTAssertEqual(lhs + rhs, expected) + XCTAssertEqual(rhs + lhs, expected) + } + } + + func testSubtraction() { + let fractionFractionCases: [(lhs: Fraction, rhs: Fraction, expected: Fraction)] = [ + ((1|2), (3|5), -2|20), + ((1|1), (17|11), -6|11), + ((4|2), (3 | -5), 13|5), + ((4|2), (3 | -5), 26|10), + ] + for (lhs, rhs, expected) in fractionFractionCases { + XCTAssertEqual(lhs - rhs, expected) + } + + let fractionIntCases: [(lhs: Fraction, rhs: Int, expected: Fraction)] = [ + ((1|2), 10, -19|2), + ((1|1), 1, 0), + ((171|24), 1, 147|24), + ((4|2), -1, 3), + ((4|2), 15, -13), + ] + for (lhs, rhs, expected) in fractionIntCases { + XCTAssertEqual(lhs - rhs, expected) + XCTAssertEqual(rhs - lhs, -lhs + rhs) + } + } + + func testStringEncoding() { + let testCases: [(Fraction, expected: String)] = [ + ((1|2), "(1|2)"), + ((1|1), "1"), + ((0|1), "0"), + ((0 | -1), "0"), + ((-0|1), "0"), + ((-5|1), "-5"), + ((5 | -1), "-5"), + ((-5|7), "(-5|7)"), + ((5 | -7), "(-5|7)"), + ((-5 | -7), "(5|7)"), + ((-5 | -7), "(5|7)"), + ] + for (fraction, expected) in testCases { + XCTAssertEqual(fraction.description, expected) + } + + XCTAssertNil(Fraction("(")) + XCTAssertNil(Fraction("(1")) + XCTAssertNil(Fraction("(1|")) + XCTAssertNil(Fraction("(1|2")) + XCTAssertEqual(Fraction("(1|2)"), 1|2) + XCTAssertNil(Fraction("(1|3|2)")) + XCTAssertNil(Fraction("1|2)")) + XCTAssertNil(Fraction("|2)")) + XCTAssertNil(Fraction("2)")) + XCTAssertNil(Fraction(")")) + } +} diff --git a/Tests/UnitsTests/UnitTests.swift b/Tests/UnitsTests/UnitTests.swift index 0404a07..4cf6466 100644 --- a/Tests/UnitsTests/UnitTests.swift +++ b/Tests/UnitsTests/UnitTests.swift @@ -7,6 +7,8 @@ final class UnitTests: XCTestCase { XCTAssertNotEqual(Unit.meter, Unit.foot) XCTAssertEqual(Unit.meter * Unit.second / Unit.second, Unit.meter) XCTAssertNotEqual(Unit.newton, Unit.kilogram * Unit.meter / Unit.second.pow(2)) + XCTAssertNotEqual(Unit.meter.pow(1.over(2)), Unit.kilogram * Unit.meter / Unit.second.pow(2)) + XCTAssertEqual(Unit.meter.pow(1.over(6)), Unit.meter.pow(1.over(2)).pow(1.over(3)) ) } func testIsDimensionallyEquivalent() throws { @@ -28,6 +30,10 @@ final class UnitTests: XCTestCase { XCTAssertTrue( (Unit.newton).isDimensionallyEquivalent(to: Unit.kilogram * Unit.meter / Unit.second.pow(2)) ) + XCTAssertTrue( + Unit.meter.pow(1.over(6)).isDimensionallyEquivalent(to: Unit.meter.pow(1.over(2)).pow(1.over(3))) + ) + } func testMultiply() throws { @@ -36,6 +42,11 @@ final class UnitTests: XCTestCase { Unit.meter.pow(2) ) + XCTAssertEqual( + Unit.meter.pow(2) * Unit.meter.pow(1.over(2)), + Unit.meter.pow(5.over(2)) + ) + // Test that cancelling units give nil XCTAssertEqual( Unit.meter.pow(-1) * Unit.meter, @@ -49,6 +60,11 @@ final class UnitTests: XCTestCase { Unit.meter ) + XCTAssertEqual( + Unit.meter.pow(2) / Unit.meter.pow(1.over(2)), + Unit.meter.pow(3.over(2)) + ) + // Test that cancelling units give nil XCTAssertEqual( Unit.meter / Unit.meter, @@ -62,6 +78,21 @@ final class UnitTests: XCTestCase { Unit.meter * Unit.meter * Unit.meter ) + XCTAssertEqual( + Unit.meter.pow(2).pow(1.over(2)), + Unit.meter + ) + + XCTAssertEqual( + Unit.meter.pow(3).pow(1.over(2)), + Unit.meter.pow(3.over(2)) + ) + + XCTAssertEqual( + Unit.meter.pow(2).pow(3), + Unit.meter.pow(6) + ) + // Test dividing by powers works (order of operations is preserved) XCTAssertEqual( Unit.meter / Unit.second.pow(2), @@ -116,6 +147,11 @@ final class UnitTests: XCTestCase { "m/s^2" ) + XCTAssertEqual( + (Unit.meter / Unit.second.pow(2.over(5))).symbol, + "m/s^(2|5)" + ) + XCTAssertEqual( (Unit.none).symbol, "none" @@ -157,6 +193,11 @@ final class UnitTests: XCTestCase { (Unit.meter / Unit.second.pow(2)).name, "meter / second^2" ) + + XCTAssertEqual( + (Unit.meter / Unit.second.pow(2.over(5))).name, + "meter / second^(2|5)" + ) } func testDimension() throws { @@ -235,6 +276,11 @@ final class UnitTests: XCTestCase { Unit.meter.pow(2) ) + XCTAssertEqual( + try Unit(fromSymbol: "m^(2|5)"), + Unit.meter.pow(2.over(5)) + ) + XCTAssertEqual( try Unit(fromSymbol: "1/s"), Unit.second.pow(-1)