diff --git a/CLAUDE.md b/CLAUDE.md index 6053010e..dceb7dc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,7 +110,7 @@ These are now baked into the generator and enforced by tests. **Do not reopen wi 2. **Dimensionless and angular quantities have both `Ratio` (V0) and `SignedRatio` (V1) bases.** Ratios that semantically must be non-negative (e.g. `RefractiveIndex`, `MachNumber`, `SpecificGravity`) are V0 overloads of `Ratio`. 3. **Semantic overloads widen implicitly to their base, narrow explicitly from it.** A `Weight` is implicitly a `ForceMagnitude`; the reverse requires `Weight.From(forceMagnitude)` or an explicit cast. 4. **Physical constraints are enforced structurally via the V0 (magnitude) form.** `Vector0` factories run `Vector0Guards.EnsureNonNegative` and throw `ArgumentException` on a negative value. That covers absolute zero (Temperature is V0, so Kelvin must be ≥ 0), non-negative frequency, non-negative absolute pressure, etc. A V0 *overload* can opt into a stricter rule by declaring `physicalConstraints: { "minExclusive": "0" }` in `dimensions.json` (#51); the generator then emits `Vector0Guards.EnsurePositive` and rejects zero too. Used today for `Wavelength`, `Period`, and `HalfLife` — quantities for which zero is unphysical. -5. **Logarithmic-scale quantities are generated from `logarithmic.json`, not declared as dimensions.** Decibel scales (`Decibels`, `SoundPressureLevel`, `SoundIntensityLevel`, `SoundPowerLevel`, `DirectionalityIndex`), pitch intervals (`Cents`, `Semitones`), and `PH` don't obey linear arithmetic, so they are emitted by `LogarithmicScalesGenerator` as standalone `readonly partial record struct`s built around `scale = multiplier · log_base(linear / reference)`, converting to and from their linear generated counterparts (`Gain`, `Ratio`, `SoundPressure`, `SoundIntensity`, `SoundPower`, `Concentration`). Bespoke members (named constants like `PH.Neutral`, cross-scale conversions like `Cents`↔`Semitones`) live in hand-written partials next to the metadata-generated core. Adding a new log-scale quantity means adding a `logarithmic.json` entry, plus a partial only if it needs bespoke members. +5. **Logarithmic-scale quantities are generated from `logarithmic.json`, not declared as dimensions.** Decibel scales (`Decibels`, `SoundPressureLevel`, `SoundIntensityLevel`, `SoundPowerLevel`, `DirectionalityIndex`), pitch intervals (`Cents`, `Semitones`), and `PH`/`POH` don't obey linear arithmetic, so they are emitted by `LogarithmicScalesGenerator` as standalone `readonly partial record struct`s built around `scale = multiplier · log_base(linear / reference)`, converting to and from their linear generated counterparts (`Gain`, `Ratio`, `SoundPressure`, `SoundIntensity`, `SoundPower`, `Concentration`). Bespoke members (named constants like `PH.Neutral`, cross-scale conversions like `Cents`↔`Semitones` and `PH`↔`POH`) live in hand-written partials next to the metadata-generated core. Adding a new log-scale quantity means adding a `logarithmic.json` entry, plus a partial only if it needs bespoke members. ### The C++ projection diff --git a/Semantics.Quantities.Decimal/build/ktsu.Semantics.Quantities.Decimal.props b/Semantics.Quantities.Decimal/build/ktsu.Semantics.Quantities.Decimal.props index 053b32a6..a0afbb69 100644 --- a/Semantics.Quantities.Decimal/build/ktsu.Semantics.Quantities.Decimal.props +++ b/Semantics.Quantities.Decimal/build/ktsu.Semantics.Quantities.Decimal.props @@ -145,6 +145,7 @@ + diff --git a/Semantics.Quantities.Double/build/ktsu.Semantics.Quantities.Double.props b/Semantics.Quantities.Double/build/ktsu.Semantics.Quantities.Double.props index f07b188e..20e3ea89 100644 --- a/Semantics.Quantities.Double/build/ktsu.Semantics.Quantities.Double.props +++ b/Semantics.Quantities.Double/build/ktsu.Semantics.Quantities.Double.props @@ -145,6 +145,7 @@ + diff --git a/Semantics.Quantities.Float/build/ktsu.Semantics.Quantities.Float.props b/Semantics.Quantities.Float/build/ktsu.Semantics.Quantities.Float.props index 66ab02e0..ef2a1ce4 100644 --- a/Semantics.Quantities.Float/build/ktsu.Semantics.Quantities.Float.props +++ b/Semantics.Quantities.Float/build/ktsu.Semantics.Quantities.Float.props @@ -145,6 +145,7 @@ + diff --git a/Semantics.Quantities.Precise/build/ktsu.Semantics.Quantities.Precise.props b/Semantics.Quantities.Precise/build/ktsu.Semantics.Quantities.Precise.props index 13a8a9d0..6b846d60 100644 --- a/Semantics.Quantities.Precise/build/ktsu.Semantics.Quantities.Precise.props +++ b/Semantics.Quantities.Precise/build/ktsu.Semantics.Quantities.Precise.props @@ -145,6 +145,7 @@ + diff --git a/Semantics.Quantities/Chemistry/PH.cs b/Semantics.Quantities/Chemistry/PH.cs index bf8332cb..1cf41525 100644 --- a/Semantics.Quantities/Chemistry/PH.cs +++ b/Semantics.Quantities/Chemistry/PH.cs @@ -17,8 +17,8 @@ public readonly partial record struct PH /// /// Gets the complementary pOH at 25 °C using pOH = 14 − pH. /// - /// The pOH as a scale value. - public PH ToPOH() => new(T.CreateChecked(14) - Value); + /// The pOH as a scale value. + public POH ToPOH() => POH.Create(T.CreateChecked(14) - Value); /// Gets whether this value describes an acid (pH below 7). public bool IsAcidic => Value < PhysicalConstants.Generic.NeutralPH(); diff --git a/Semantics.Quantities/Chemistry/POH.cs b/Semantics.Quantities/Chemistry/POH.cs new file mode 100644 index 00000000..b5a13398 --- /dev/null +++ b/Semantics.Quantities/Chemistry/POH.cs @@ -0,0 +1,19 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Quantities; + +using System.Numerics; + +/// +/// Bespoke members of ; the logarithmic core (including the +/// conversions) is generated from logarithmic.json. +/// +public readonly partial record struct POH + where T : struct, INumber +{ + /// + /// Gets the complementary pH at 25 °C using pH = 14 − pOH. + /// + /// The pH as a scale value. + public PH ToPH() => PH.Create(T.CreateChecked(14) - Value); +} diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.LogarithmicScalesGenerator/POH.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.LogarithmicScalesGenerator/POH.g.cs new file mode 100644 index 00000000..15f56c62 --- /dev/null +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.LogarithmicScalesGenerator/POH.g.cs @@ -0,0 +1,109 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors +// + +#nullable enable + +namespace ktsu.Semantics.Quantities; + +using System; +using System.Globalization; +using System.Numerics; + +/// +/// Represents basicity on the pOH scale: pOH = −log10([OH⁻]) with the hydroxide-ion activity in mol/L. +/// +/// +/// pOH is a logarithmic scale over the linear Concentration quantity (stored in the SI base mol/m³; 1 mol/L = 1000 mol/m³). +/// Logarithmic scales don't obey linear arithmetic, so this type is generated as a +/// standalone companion (from logarithmic.json) rather than a physical dimension. +/// +/// Precision: the conversions to and from the linear counterparts compute in +/// whatever is, so a value carried in a +/// storage type wider than — , or +/// PreciseNumber — is accurate to about fifteen significant digits once it has been +/// through one. Arithmetic, comparison and the raw factory keep every digit +/// holds; only the logarithm and its inverse do not. +/// +/// +/// The floating-point storage type. +/// The scale value. +public readonly partial record struct POH(T Value) : IComparable> + where T : struct, INumber +{ + /// + /// Creates a value from the raw scale number. + /// + /// The raw scale value. + /// A new . + public static POH Create(T value) => new(value); + + /// + /// Creates a pOH from a hydroxide-ion concentration using pOH = −log10([OH⁻] in mol/L). + /// + /// The linear . + /// A new . A linear value of zero maps to negative infinity where can hold it. + /// + /// Computes in whatever is: the value is + /// converted to , the logarithm or power is taken there, and the + /// result is converted back. A storage type wider than — + /// , or PreciseNumber — therefore keeps about fifteen + /// significant digits across this conversion, rather than the precision it is capable of. + /// + /// cannot hold the result, as for a linear value of zero with or integer storage. + public static POH FromHydroxideConcentration(Concentration linear) + { + double linearValue = double.CreateChecked(linear.Value); + double reference = 1000.0; + return new(LogarithmicStorage.FromLinear(-1.0 * Math.Log10(linearValue / reference), nameof(linear))); + } + + /// + /// Converts this pOH to the equivalent hydroxide-ion concentration using [OH⁻] = 10^(−pOH) mol/L. + /// + /// The linear . + /// + /// Computes in whatever is: the value is + /// converted to , the logarithm or power is taken there, and the + /// result is converted back. A storage type wider than — + /// , or PreciseNumber — therefore keeps about fifteen + /// significant digits across this conversion, rather than the precision it is capable of. + /// + /// cannot hold the linear value of a level this high. + public Concentration ToHydroxideConcentration() + { + double scaleValue = double.CreateChecked(Value); + double reference = 1000.0; + return Concentration.Create(LogarithmicStorage.ToLinear(reference * Math.Pow(10.0, scaleValue / -1.0))); + } + + /// + public int CompareTo(POH other) => Value.CompareTo(other.Value); + + /// Determines whether one value is less than another. + /// The left value. + /// The right value. + /// if is less than . + public static bool operator <(POH left, POH right) => left.CompareTo(right) < 0; + + /// Determines whether one value is greater than another. + /// The left value. + /// The right value. + /// if is greater than . + public static bool operator >(POH left, POH right) => left.CompareTo(right) > 0; + + /// Determines whether one value is less than or equal to another. + /// The left value. + /// The right value. + /// if is less than or equal to . + public static bool operator <=(POH left, POH right) => left.CompareTo(right) <= 0; + + /// Determines whether one value is greater than or equal to another. + /// The left value. + /// The right value. + /// if is greater than or equal to . + public static bool operator >=(POH left, POH right) => left.CompareTo(right) >= 0; + + /// Returns a culture-invariant string representation of this value. + /// The formatted value. + public override string ToString() => string.Create(CultureInfo.InvariantCulture, $"pOH {Value}"); +} diff --git a/Semantics.SourceGenerators/Metadata/logarithmic.json b/Semantics.SourceGenerators/Metadata/logarithmic.json index 75fa21c9..5ef95f60 100644 --- a/Semantics.SourceGenerators/Metadata/logarithmic.json +++ b/Semantics.SourceGenerators/Metadata/logarithmic.json @@ -151,6 +151,25 @@ "toSummary": "Converts this pH to the equivalent hydrogen-ion concentration using [H⁺] = 10^(−pH) mol/L." } ] + }, + { + "name": "POH", + "description": "Represents basicity on the pOH scale: pOH = −log10([OH⁻]) with the hydroxide-ion activity in mol/L.", + "remarks": "pOH is a logarithmic scale over the linear Concentration quantity (stored in the SI base mol/m³; 1 mol/L = 1000 mol/m³).", + "displayFormat": "pOH {0}", + "scalarFactory": "Create", + "arithmetic": false, + "conversions": [ + { + "linear": "Concentration", + "multiplier": "-1", + "reference": { "value": "1000" }, + "fromName": "FromHydroxideConcentration", + "toName": "ToHydroxideConcentration", + "fromSummary": "Creates a pOH from a hydroxide-ion concentration using pOH = −log10([OH⁻] in mol/L).", + "toSummary": "Converts this pOH to the equivalent hydroxide-ion concentration using [OH⁻] = 10^(−pOH) mol/L." + } + ] } ] } diff --git a/Semantics.Test/Quantities/QuantityBackfillTests.cs b/Semantics.Test/Quantities/QuantityBackfillTests.cs index f7475cf2..ba7a52e1 100644 --- a/Semantics.Test/Quantities/QuantityBackfillTests.cs +++ b/Semantics.Test/Quantities/QuantityBackfillTests.cs @@ -177,6 +177,27 @@ public void PH_Acidity_Classification_And_POH() Assert.IsFalse(PH.Neutral.IsAcidic); } + [TestMethod] + public void POH_Of_An_Acid_Is_A_POH_And_Converts_Back() + { + // #482: ToPOH returned a PH, so pH 3 reported its pOH as a basic "pH 11". + POH poh = PH.Create(3.0).ToPOH(); + Assert.AreEqual(11.0, poh.Value, Tolerance); + Assert.StartsWith("pOH ", poh.ToString()); + Assert.AreEqual(Concentration.FromMolar(1e-11).Value, poh.ToHydroxideConcentration().Value, 1e-20); + Assert.AreEqual(3.0, poh.ToPH().Value, Tolerance); + Assert.IsTrue(poh.ToPH().IsAcidic); + } + + [TestMethod] + public void POH_From_Hydroxide_Concentration_And_Back() + { + POH poh = POH.FromHydroxideConcentration(Concentration.FromMolar(1e-4)); + Assert.AreEqual(4.0, poh.Value, 1e-9); + Assert.AreEqual(10.0, poh.ToPH().Value, 1e-9); + Assert.AreEqual(Concentration.FromMolar(1e-4).Value, poh.ToHydroxideConcentration().Value, 1e-12); + } + [TestMethod] public void Levels_Compare_And_Add_In_Decibel_Space() { diff --git a/docs/migration-guide-6.0.md b/docs/migration-guide-6.0.md index 30a8186f..c041bddf 100644 --- a/docs/migration-guide-6.0.md +++ b/docs/migration-guide-6.0.md @@ -1,5 +1,29 @@ # Migrating from Semantics 5.x to 6.0 +## `PH.ToPOH()` returns a `POH` + +`ToPOH()` used to return its pOH inside a `PH`, so every pH member read it as a pH: the pOH +of an acid reported `IsBasic == true`, printed as `pH 11`, and `ToHydrogenConcentration()` +returned the hydroxide-ion concentration instead (#482). + +It now returns the new `POH` scale, generated from `logarithmic.json` like `PH`. `POH` +has no `IsAcidic`, `IsBasic` or pH formatting; it prints as `pOH {0}`, converts to and from the +hydroxide-ion `Concentration`, and `ToPH()` gives the complementary pH at 25 °C. + +```csharp +// 5.x +PH poh = PH.Create(3).ToPOH(); // "pH 11", IsBasic == true + +// 6.0 +POH poh = PH.Create(3).ToPOH(); // "pOH 11" +PH ph = poh.ToPH(); // pH 3 +Concentration oh = poh.ToHydroxideConcentration(); // 1e-11 mol/L +``` + +Code that only read `.Value` keeps compiling if it uses `var`, and the number is unchanged. +Code that declared the result as `PH` gets `CS0029`; declare it as `POH`, or call +`.ToPH()` if a pH was what it wanted. + ## A directory's `Name` is a `DirectoryName` `AbsoluteDirectoryPath.Name` and `DirectoryPath.Name` used to return a `FileName`, while