diff --git a/README.md b/README.md index 42ce9cd..dd64c86 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,55 @@ let weeklyCartons = try (workforce * personPickRate).convert(to: carton / .week) print(weeklyCartons) // Prints '350.0 carton/week' ``` +### Percent + +While technically not a `Measurement`, the use of the percent symbol ('%') is still useful +in conveying the "semanantics" of a scaler value so we include it in this package. + +Here’s how math operators work with percentages in typical calculations: + +Math operators with percentages treat the percent as its decimal equivalent +(e.g., 25% = 0.25) but in the case of `+` and `-` the calculation is less direct. + +#### Multiplication (100 * 25%) + + When you multiply a number by a percentage, you’re finding that percent of the number. + • 25% is the same as 0.25. + • So, 100 * 25% = 100 * 0.25 = 25. + +#### Division (100 / 30%) + + Dividing by a percentage means dividing by its decimal form. + • 30% is 0.3. + • So, 100 / 30% = 100 / 0.3 ≈ 333.33. + +#### Addition (100 + 10%) + + Adding a percentage to a number is less direct, but usually means increasing the number by that percent. + • 10% of 100 is 10. + • So, 100 + 10% = 100 + (100 * 0.10) = 110. + +#### General Rule + • Percent means “per hundred,” so 25% = 25/100 = 0.25. + • Replace the percent with its decimal equivalent before performing the operation. + + +### FormatStyle + +The `Measurement.Formatter` provides the `formatted` method to enable the +setting of the `NumberFormatStyleConfiguration.Precision` for String output. + +Example Use: + +``` + let measure = 28.123.measured(in: .meter) + + measure.formatted() // -> "28.123 m" + measure.formatted(minimumFractionDigits: 4) // -> "28.1230 m" + measure.formatted(maximumFractionDigits: 0) // -> "28 m" + measure.formatted(maximumFractionDigits: 1) // -> "28.1 m" +``` + ## CLI The easiest way to install the CLI is with brew: diff --git a/Sources/Units/Inventory.swift b/Sources/Units/Inventory.swift new file mode 100644 index 0000000..d29c1da --- /dev/null +++ b/Sources/Units/Inventory.swift @@ -0,0 +1,157 @@ +// +// Inventory.swift +// Units +// +// Created by Jason Jobe on 1/28/26. +// +import Foundation + +// MARK: SKU for Inventory +public typealias SKU = URL +public typealias Stock = Quantum + +public extension QuantumType { + var sku: SKU { URL(sku: "\(self)") } +} + +// MARK: Inventory - a collection of Quamtum +public struct Inventory { + public private(set) var items: [Stock] + public var count: Int { items.count } +} + +public extension Inventory { + + func adding(_ q: Quantum) -> Self { + var newSelf = self + newSelf.add(q) + return newSelf + } + + func subtracting(_ q: Quantum) -> Self { + var newSelf = self + newSelf.subtract(q) + return newSelf + } + + mutating func subtract(_ q: Quantum) { + if let ndx = items.firstIndex(where: { $0.qtype == q.qtype }) { + items[ndx].magnitude -= q.magnitude + if items[ndx].magnitude == 0 { + items.remove(at: ndx) + } + } else { + items.append(q) + } + } + + mutating func add(_ q: Quantum) { + if let ndx = items.firstIndex(where: { $0.qtype == q.qtype }) { + items[ndx].magnitude += q.magnitude + if items[ndx].magnitude == 0 { + items.remove(at: ndx) + } + } else { + items.append(q) + } + } + + func amount(of qt: QuantumType) -> Double { + items.reduce(0) { partial, elem in partial + (elem.qtype == qt ? elem.magnitude : 0) } + } +} + +public extension Inventory { + static func += (lhs: inout Self, rhs: Quantum) { + lhs.add(rhs) + } + static func -= (lhs: inout Self, rhs: Quantum) { + lhs.subtract(rhs) + } +} + +public extension Inventory { + static func += (lhs: inout Self, rhs: Inventory) { + rhs.items.forEach { lhs.add($0) } + } + + static func -= (lhs: inout Self, rhs: Inventory) { + rhs.items.forEach { lhs.subtract($0) } + } + + static func *= (lhs: inout Self, rhs: Double) { + for i in lhs.items.indices { + lhs.items[i].magnitude *= rhs + } + } + + static func /= (lhs: inout Self, rhs: Double) { + for i in lhs.items.indices { + lhs.items[i].magnitude /= rhs + } + } + + static func + (lhs: Self, rhs: Inventory) -> Inventory { + var result = lhs + for element in rhs.items { + result.add(element) + } + return result + } +} + +// MARK: SKU Extenstion to URL +extension URL { + /** + A SKU URL has the scheme 'sku' and has the general format of + sku:/:=material[25units] + Guarenteed to create something + */ + public init(sku: String) { + self = Self.parse(sku: sku) + } + + public static func parse(sku: String) -> URL { + if sku.lowercased().hasPrefix("sku:") { + if let url = URL(string: sku) { + return url + } + } + + // Ensure we always create a valid URL with the `sku` scheme. + // We build components and percent-encode the path so arbitrary SKU tokens are safe. + var comps = URLComponents() + comps.scheme = "sku" + + // Treat the whole string as the URL path + // Valid URL MUST have single leading slash + let rawPath = sku.hasPrefix("/") ? sku : "/" + sku + // URLComponents expects path to be unescaped + // it will handle encoding when producing .url + comps.path = rawPath + + if let url = comps.url { + return url + } + + // Absolute fallback: construct from a minimally encoded string. + // Replace spaces with %20 and remove illegal characters conservatively. + let allowed = CharacterSet.urlPathAllowed.union(CharacterSet(charactersIn: "/")) + let encodedPath = rawPath.unicodeScalars.map { allowed.contains($0) ? String($0) : String(format: "%%%02X", $0.value) }.joined() + return URL(string: "sku:\\" + encodedPath) + ?? URL(string: "sku:/UNKOWN")! + } +} + +#if PLAY_TIME +import Playgrounds +#Playground { + if let sku = URL(string: "sku:joe@wildthink.com/role[3]:=12345") + { + print("SKU:", sku) + print("SKU.path:", sku.path) + } else { + print("BAD") + } +} +#endif diff --git a/Sources/Units/Measurement/Formatter.swift b/Sources/Units/Measurement/Formatter.swift new file mode 100644 index 0000000..90f92aa --- /dev/null +++ b/Sources/Units/Measurement/Formatter.swift @@ -0,0 +1,105 @@ +public extension Measurement { + struct Formatter { + let format: (Measurement) -> Output + } + + func formatted(_ formatter: Formatter) -> Output { + formatter.format(self) + } + + func formatted(_ formatter: Formatter = .measurement()) -> String { + formatter.format(self) + } + + func formatted( + minimumFractionDigits: Int = 0, + maximumFractionDigits: Int = 4 + ) -> String { + Formatter + .measurement( + minimumFractionDigits: minimumFractionDigits, + maximumFractionDigits: maximumFractionDigits) + .format(self) + } +} + +extension Measurement.Formatter where Output == String { + public static func measurement( + minimumFractionDigits: Int = 0, + maximumFractionDigits: Int = 4 + ) -> Self { + self.init { value in + value.value + .formatted( + minimumFractionDigits: minimumFractionDigits, + maximumFractionDigits: maximumFractionDigits) + + " \(value.unit.symbol)" + } + } +} + + +// MARK: Percent Formatter +// Implementation +import Foundation +extension NumberFormatter { + func string(from measurement: Measurement) -> String { + return "\(self.string(from: .init(value: measurement.value)) ?? "BAD") \(measurement.unit.symbol)" + } +} + +// Usage +//let measurement = 28.123.measured(in: .meter) +//let formatter = NumberFormatter() +//formatter.maximumFractionDigits = 2 +//print(formatter.string(from: measurement)) // Prints `28.12 m` + +public extension Percent { + struct Formatter { + let format: (Percent) -> Output + } + + func formatted(_ formatter: Formatter) -> Output { + formatter.format(self) + } +} + +public extension Percent { + func formatted(fractionDigits: Int = 2) -> String { + Formatter(fractionDigits: fractionDigits).format(self) + } +} + +public extension Percent.Formatter where Output == String { + + init (fractionDigits: Int) { + self.init { value in + (value.magnitude * 100) + .formatted( + minimumFractionDigits: 0, + maximumFractionDigits: fractionDigits) + + value.unit.symbol + } + } +} + +public extension BinaryFloatingPoint { + func formatted(minimumFractionDigits: Int = 0, maximumFractionDigits: Int = 4) -> String { + let minDigits = max(0, minimumFractionDigits) + let maxDigits = max(minDigits, maximumFractionDigits) + let s = String(format: "%.\(maxDigits)f", Double(self)) + if maxDigits > minDigits, s.contains(".") { + var trimmed = s + while trimmed.last == "0" { trimmed.removeLast() } + if trimmed.last == "." { trimmed.removeLast() } + if let dotIndex = trimmed.firstIndex(of: ".") { + let fractionalCount = trimmed.distance(from: trimmed.index(after: dotIndex), to: trimmed.endIndex) + if fractionalCount < minDigits { + return String(format: "%.\(minDigits)f", Double(self)) + } + } + return trimmed + } + return s + } +} diff --git a/Sources/Units/Measurement/Percent+Measurement.swift b/Sources/Units/Measurement/Percent+Measurement.swift index 154bee8..f18782d 100644 --- a/Sources/Units/Measurement/Percent+Measurement.swift +++ b/Sources/Units/Measurement/Percent+Measurement.swift @@ -43,7 +43,7 @@ import Foundation If you see a percent sign in a calculation, just convert it to a decimal and proceed as usual. If you want to know how subtraction works with percentages, or how to handle more complex expressions, let me know! */ -public struct Percent: Numeric, Equatable, Codable { +public struct Percent: Numeric, Codable, Sendable { public private(set) var magnitude: Double @@ -65,6 +65,14 @@ public struct Percent: Numeric, Equatable, Codable { } } +extension Percent: Equatable { + /// Implemented as "nearly" equal + public static func ==(lhs: Percent, rhs: Percent) -> Bool { + lhs.magnitude >= rhs.magnitude.nextDown + && lhs.magnitude <= lhs.magnitude.nextUp + } +} + extension Measurement { public var isPercent: Bool { self.unit == Percent.unit @@ -201,3 +209,23 @@ public extension Measurement { ) } } + +extension Percent: Comparable { + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.magnitude < rhs.magnitude + } +} + +extension Percent { + /** + Returns a random value within the given range. + + ``` + Percent.random(in: 10%...20%) + // 10%, 11%, 12%, 19.98%, etc. + ``` + */ + public static func random(in range: ClosedRange) -> Self { + self.init(magnitude: .random(in: range.lowerBound.magnitude...range.upperBound.magnitude)) + } +} diff --git a/Sources/Units/Quantum.swift b/Sources/Units/Quantum.swift new file mode 100644 index 0000000..dc81072 --- /dev/null +++ b/Sources/Units/Quantum.swift @@ -0,0 +1,93 @@ +// +// Quantum.swift +// Units +// +// Created by Jason Jobe on 1/17/26. +// + +import Foundation + +/** + Quantum means that which is divisible into two or more constituent parts, + of which each is by nature a one and a this. A quantum is a plurality if it is numerable, + a magnitude if it is measurable. Plurality means that which is divisible potentially into + non-continuous parts, magnitude that which is divisible into continuous parts; + of magnitude, that which is continuous in one dimension is length; + in two breadth, in three depth. + + Of these, limited plurality is number, limited length is a line, breadth a surface, depth a solid. + +— Aristotle, Metaphysics, Book V, Ch. 11-14 + */ + +/* + Plurality - Numerable, Countable + Magnitude - Measurable in Units + Compound/Mixture - + */ + +public struct QuantumType: Equatable, Identifiable, Codable, Sendable { + public var id: String { token } + public let token: String + public let unit: Unit +} + +extension QuantumType: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + token = value + unit = .none + } + + public init(_ value: any StringProtocol, unit: Unit = .none) { + token = value.description + self.unit = unit + } +} + +public struct Quantum: Equatable { + public let qtype: QuantumType + public var magnitude: Double +} + +public extension Quantum { + var unit: Unit { qtype.unit } + var count: Double { magnitude } + var isPlurality: Bool { unit == .none } + var isMeasurable: Bool { unit != .none } + var countable: Bool { unit == .none } +} + +/* + Equation - Mathematical expressions with single character variables + + Money - System of Currency exchange + Currency: EU, USD, etc + + ## ExtendedSwiftMath + https://swiftpackageindex.com/ChrisGVE/ExtendedSwiftMath + + An extended version of SwiftMath with comprehensive LaTeX + symbol coverage, adding missing mathematical symbols, + blackboard bold, delimiter sizing, amssymb equivalents, + and automatic line wrapping. + + ## thales + https://swiftpackageindex.com/ChrisGVE/thales + Full Documentation on docs.rs + + A comprehensive Computer Algebra System (CAS) library for + symbolic mathematics, equation solving, calculus, and numerical + methods. Named after Thales of Miletus, the first mathematician + in the Greek tradition. + + Features + + - Expression Parsing - Parse mathematical expressions with full operator precedence + - Equation Solving - Linear, quadratic, polynomial, transcendental, and systems of equations + - Calculus - Differentiation, integration, limits, Taylor series, ODEs + - Numerical Methods - Newton-Raphson, bisection, Brent's method when symbolic fails + - Coordinate Systems - 2D/3D transformations, complex numbers, De Moivre's theorem + - Units & Dimensions - Dimensional analysis and unit conversion + - iOS Support - FFI bindings for Swift via swift-bridge + */ + diff --git a/Tests/UnitsTests/MeasurementTests.swift b/Tests/UnitsTests/MeasurementTests.swift index cbb59d8..1e0a746 100644 --- a/Tests/UnitsTests/MeasurementTests.swift +++ b/Tests/UnitsTests/MeasurementTests.swift @@ -600,4 +600,12 @@ final class MeasurementTests: XCTestCase { accuracy: accuracy ) } + + func testFormatStyle() { + let measure = 28.123.measured(in: .meter) + XCTAssertEqual(measure.formatted(), "28.123 m") + XCTAssertEqual(measure.formatted(minimumFractionDigits: 4), "28.1230 m") + XCTAssertEqual(measure.formatted(maximumFractionDigits: 0), "28 m") + XCTAssertEqual(measure.formatted(maximumFractionDigits: 1), "28.1 m") + } } diff --git a/Tests/UnitsTests/PercentTests.swift b/Tests/UnitsTests/PercentTests.swift index 2cd673f..f72a679 100644 --- a/Tests/UnitsTests/PercentTests.swift +++ b/Tests/UnitsTests/PercentTests.swift @@ -9,6 +9,7 @@ import XCTest final class PercentTests: XCTestCase { + func testParse() throws { XCTAssertEqual( try Expression("10m + 25%"), @@ -38,7 +39,20 @@ final class PercentTests: XCTestCase { try Expression("10m / 25%").solve(), 40.measured(in: .meter) ) - + } + + func testPercentCalculation() { + XCTAssertEqual(50% * 50%, 25%) + XCTAssertEqual(50% + 5.8%, 55.8%) + XCTAssertEqual(50% - 50%, 0%) + XCTAssertEqual(50% - 5.8%, 44.2%) + } + + func testPercentFormat() { + XCTAssertEqual(30%.formatted(), "30%") + XCTAssertEqual(28.5%.formatted(), "28.5%") + XCTAssertEqual(28.33%.formatted(), "28.33%") + XCTAssertEqual(28.33%.formatted(fractionDigits: 1), "28.3%") + XCTAssertEqual(28.33%.formatted(fractionDigits: 0), "28%") } } -