QuantityValue implemented as a fractional number 🐲 #1544
Conversation
- IQuantity interfaces optimized (some methods refactored as extensions) - QuantityInfo/UnitInfo hierachy re-implemented (same properties, different constructors) - QuantityInfoLookup is now public - UntAbbreviationsCache, UnitParser, QuantityParser optimized - UnitConverter: re-implemented (multiple versions) - removed the IConvertible interface - updated the JsonNet converters - introducing the SystemTextJson project - added a new UnitsNetConfiguration to the Samples project showcasing the new configuration options - many more tests and benchmarks (perhaps too many)
|
@angularsen Clearly, I don't expect this to get merged in the Gitty up! fashion, but at least we have the whole picture, with sources that I can reference. If you want, send me an e-mail, we could do a quick walk-through / discussion. |
…lection constructors with IEnumerable - `UnitAbbreviationsCacheInitializationBenchmarks`: replaced some obsolete usages
I tried to create this PR twice before (many months ago), while the changes to the unit definitions were still not merged- and the web interface was giving me an error when trying to browse the files changed.. Something like "Too many files to display" 😄 |
|
Ok, I'm not going to get through a review of this many files anytime soon. On the surface though, it seems like this could be split up into chunks. I know it's tedious and extra work, but it will be way faster to review. Do you see any chunks of changes to easily split off into separate PRs? |
Sofia (GMT+3), but time zones are not relevant to my sleep schedule - so basically any time you want.
Yes, I do have some ideas:
Hopefully by the time we get to 5) you'd be up to speed (and fed up with PRs) and we can turn back to reviewing / working on the rest of it as a whole 😄 |
|
Ok, sounds good. Just send PRs my way and I'll try to get to them. I have a little bit of extra time this weekend. |
…ions into their own folder
- UnitAbbreviationsCache: removing the obsolete overload - adding a few more tests
|
This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days. |
|
This PR was automatically closed due to inactivity. |
…ramework (net8.0) with 9.0 on the QuantityInfoBenchmarks
|
@angularsen I'm sorry, but P0 is a no-go. I have been very explicit about it since the very beginning (even before this PR). |
|
I see, I'll try and change it locally to get a better feel for why adding implicit cast is challenging. |
Change explicit operator double(QuantityValue) to implicit. This eliminates the largest consumer-facing breaking change in PR #1544. Library code: zero changes needed beyond the operator keyword. Test code: 101 Assert.Equal calls needed (double) cast to resolve xUnit overload ambiguity — a mechanical fix, not a behavioral change. Build: 0 errors, 0 warnings. Tests: all pass (4 pre-existing failures unrelated to this change). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Here is a follow up, trying to map out the two alternatives to avoid or reduce the breaking change of consumer code, while retaining the value of this PR. I had Claude implement each one and evaluate the impact on internal code and tests, as well as consunmer code. As far as I can tell, option A with bi-directional implicit cast of QV and double is very much feasible? PR 1544 Review: QuantityValue as Fractional NumberThe architectural improvements (ConversionExpression, centralized UnitConverter, builder pattern, exact rational coefficients) are solid and we want to get this merged. The main concern is the breaking change impact of replacing The problemWithout mitigation, ~60-70% of consumer code breaks on upgrade: double meters = length.Meters; // ❌ won't compile (returns QuantityValue)
double ratio = len1 / len2; // ❌ won't compile (returns QuantityValue)
Math.Round(length.Meters, 2); // ❌ won't compile
SomeApi(length.Value); // ❌ won't compileOption A: Add implicit conversion
|
| Scenario | Works? | Explanation |
|---|---|---|
double meters = length.Meters |
✅ | Implicit conversion |
double ratio = len1 / len2 |
✅ | Operator returns QV, implicit converts |
Math.Round(length.Meters, 2) |
✅ | Resolves to Math.Round(double, int) |
SomeDoubleApi(length.Meters) |
✅ | Implicit conversion |
Length * double |
✅ | double promotes to QV, uses Length * QV |
double x = double + QV |
✅ | Operator returns QV, implicit converts for assignment |
var x = length.Meters |
✅ | x is QV — implicitly converts wherever double is expected |
Trade-offs
var x = length.MetersinfersQuantityValue— consumers see this in IDE tooltips. Not a functional issue (implicit conversion handles everything), but visible.- Precision loss from
QuantityValue → doubleis silent. This is the desired trade-off for most consumers. Those who want exact precision can stay onQuantityValuedirectly. - Bidirectional implicit conversion is unusual in C#, but our empirical testing confirms it works cleanly here.
Option B: Keep double public, use QuantityValue internally (not recommended)
Revert the public API to double while keeping QuantityValue for the internal conversion pipeline. We fully implemented this to evaluate the trade-offs.
Verified empirically (branch agl/pr-1544-review-option-b-double-public)
- 608 files changed: ~10 manual source files, CodeGen templates (~15 locations),
UnitRelations.json, all 131 generated quantities regenerated, serialization adapters, test and benchmark code. - Build: 0 library errors, 0 warnings.
- Tests: 2,509 failures out of 51,899 (5%).
The failures are not test bugs
They reveal a fundamental precision loss from double storage:
| Failure category | Count | Root cause |
|---|---|---|
ToUnit_FromNonBaseUnit |
1,215 | Roundtrip conversion loses precision — double introduces rounding at each step |
ToUnit_FromIQuantity |
491 | Same root cause |
ToString |
256 | Precision-sensitive formatting |
ConversionRoundTrip |
68 | Direct roundtrip tests |
With QuantityValue, converting 3.0 feet → meters → feet is lossless (exact rational arithmetic). With double storage, each conversion step introduces floating-point rounding that compounds. This defeats a core value proposition of the PR.
Comparison
| Option A | Option B | |
|---|---|---|
| Library code changes | 1 line | 608 files |
| Build errors | 0 | 0 |
| New test failures | 0 | 2,509 (5%) |
| Precision preserved | Full | Degraded (roundtrip loss) |
| Consumer breaking changes eliminated | ~60-70% | ~60-70% |
| Future flexibility | Consumers can use QuantityValue directly |
Requires later API change |
Recommendation
Option A. One line of library code, zero new test failures, full precision preserved. It eliminates the largest consumer-facing breaking change while keeping 100% of the PR's value.
Other items to address before merge
Must do
- Revert
EmitDefaultValue = falseon DataMember —Length.Zeroserialized via DataContract should always include both Value and Unit. @angularsen flagged this.
Should do
- Document DataContract serialization as a breaking change in v6 release notes. The
_valuefield type change means existing DataContract-serialized data won't deserialize. XML surrogate works; JSON surrogate blocked by .NET runtime bug. Recommend migrating to JsonNet or System.Text.Json packages.
Post-merge
- Migration guide for this PR's breaking changes (to be expanded for full v6 later).
- Test coverage for
InterfaceQuantityWithUnitTypeConverter— appears unused/untested. - Align
QuantityValueFormatOptionsenum names —DecimalPrecisionvsExactNumbershould use consistent naming. Configureextension onUnitDefinition[]— consider wrapper type or static method for cleaner API.
Remaining breaking changes (regardless of QuantityValue approach)
| Change | v5 | v6 |
|---|---|---|
As(), ToUnit() |
Interface methods | Extension methods (some [Obsolete]) |
UnitConverter() constructor |
Public, parameterless | Removed |
SetConversionFunction / GetConversionFunction |
Available | Removed |
UnitsNetSetup constructor |
Public | Private; use builder |
| DataContract serialization | double field |
QuantityValue struct |
AbbreviatedUnitsConverter (JsonNet) |
IReadOnlyDictionary ctor |
UnitParser + options |
| Default JSON precision | ~17 digits | Up to 29 digits |
MissingMemberHandling.Error |
Silently skipped unknowns | Correctly throws (bug fix) |
Null IQuantity deserialization |
Returns .Zero |
Returns null |
Restructured for posting on PR #1544: - Leads with the problem and consumer impact - Presents both options with empirical data and comparison table - Clear recommendation for Option A (implicit cast) - Actionable items organized by priority - Remaining breaking changes reference table Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days. |
|
This PR was automatically closed due to inactivity. |
|
@angularsen I've reopened this:
|
Extracted from #1544 because deterministic code generation is independent of the QuantityValue feature and improves the existing generator on its own. Moving it out reduces the size and conflict surface of the original PR. Changes: - Deserialize relation definitions without relying on SortedSet's current-culture comparer. - Normalize relations with an ordinal, case-insensitive immutable sorted set before parsing and rewriting the file. Tests: - No test cases changed; verified by running the full code generator and confirming it produced no unrelated generated-file changes.
Extracted from #1544 because format-string parsing is unrelated to QuantityValue and is a standalone correctness improvement for the existing formatter. Moving it out keeps the feature PR focused and lets this behavior ship independently. Changes: - Parse significant-digit, abbreviation-index, currency, and percent suffixes with invariant culture. - Apply the same parsing rules to the span-based and .NET Framework-compatible code paths. Tests: - Add Format_CustomSpecifierSuffix_ParsesUsingInvariantCulture for abbreviation and significant-digit formats.
Extracted from #1544 because representing a unit system for only the dimensions an application uses is useful independently of QuantityValue. Moving it out gives the API change focused review and reduces the original PR's scope. Changes: - Allow UnitSystem to be constructed from any BaseUnits value defining at least one dimension. - Continue rejecting null and fully undefined base-unit sets. - Clarify the constructor documentation and exception message. Tests: - Change the seven previously rejected one-dimension-missing cases to assert successful construction. - Add a single-dimension UnitSystem case. - Add a dedicated assertion that BaseUnits.Undefined remains invalid.
Extracted from #1544 because composing an isolated quantity catalog is useful for custom quantities without depending on QuantityValue. Moving this API out gives the catalog design focused review and reduces the feature PR's size and initialization surface. Changes: - Add QuantitiesSelector for appending external quantity definitions to a base catalog. - Add configured factory overloads for QuantityInfoLookup, UnitAbbreviationsCache, and UnitParser. - Allow parser factories to configure abbreviations before parser construction. - Add UnitsNetSetup.Create and a selection builder for isolated, consistently wired setups. - Keep UnitsNetSetup.Default behavior unchanged. Tests: - Add QuantitiesSelector coverage for base, chained additions, ordering, and null input. - Add cache factory coverage for default and custom base catalogs. - Add parser factory coverage for external quantities and custom abbreviations. - Add isolated setup coverage for component wiring, catalog selection, additions, and duplicate base selection.
Extracted from #1544 (#1544) because choosing the global quantity catalog is an independent setup capability, not a requirement of fractional QuantityValue. Keeping it separate makes the singleton initialization and compatibility implications explicit and allows this commit to be deferred without blocking the isolated factory APIs. Changes: - Lazily create UnitsNetSetup.Default from the existing setup builder. - Add ConfigureDefaults for selecting the global catalog before first use. - Synchronize configuration and creation so concurrent first access cannot observe a partially configured builder. - Reject configuration after the singleton has been created. Tests: - Add a dedicated test assembly so global singleton state is isolated from the existing suite. - Verify configured catalog selection, default cache/parser wiring, excluded units, and rejection of reconfiguration. - Run the regression on net10.0 and net48 (1 test per target).

QuantityValueimplemented as a fractional numberIQuantityinterfaces optimized (some methods refactored as extensions)UnitInfo: introduced two new properties:ConversionFromBaseandConversionToBasewhich are used instead of theswitch(Unit)conversionUnitsNetSetup: introduced helper methods for adding external quantities, or re-configuring one or more of the existing onesUntAbbreviationsCache: introduced additional factory methods (using a configuration delegate)UnitParser: introduced additional factory methods (using a configuration delegate)UnitConverter: re-implemented (multiple versions)Inverserelationship mapping implemented as a type of implicit conversion