diff --git a/gui/src/components/editQuantity/NumberEditQuantity.js b/gui/src/components/editQuantity/NumberEditQuantity.js index 5d9627766de2557770e30950b8427ea86c8b2513..578d1ef2d418a2cd0a93a16f89c5d96b94ba7f4e 100644 --- a/gui/src/components/editQuantity/NumberEditQuantity.js +++ b/gui/src/components/editQuantity/NumberEditQuantity.js @@ -19,7 +19,8 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {TextField, makeStyles, Box, Checkbox, Tooltip} from '@material-ui/core' import Autocomplete from '@material-ui/lab/Autocomplete' import PropTypes from 'prop-types' -import {Quantity, parseQuantity} from '../units/Quantity' +import {Quantity} from '../units/Quantity' +import {parse} from '../units/common' import {Unit} from '../units/Unit' import {getUnits} from '../units/UnitContext' import {debounce, isNil} from 'lodash' @@ -92,7 +93,7 @@ export const NumberField = React.memo((props) => { } // Try to parse the quantity. Value is required, unit is optional. - const {unit: parsedUnit, value, valueString, error} = parseQuantity(input, dimension, true, false) + const {unit: parsedUnit, value, valueString, error} = parse(input, {dimension, requireValue: true}) previousNumberPart.current = valueString if (parsedUnit) { previousUnitLabel.current = parsedUnit.label() @@ -306,7 +307,7 @@ export const UnitSelect = React.memo(({options, unit, onChange, dimension, disab // Validate input and submit unit if valid const submit = useCallback((val) => { - const {unit, error} = parseQuantity(val, dimension, false, true) + const {unit, error} = parse(val, {dimension, requireUnit: true}) if (error) { setError(error) } else { diff --git a/gui/src/components/search/input/InputRange.js b/gui/src/components/search/input/InputRange.js index 01b6a37c04f868b20a8e028516ffb7399fe8507d..17c04950b0fafe6717ccadd86ec643c6f50ca87b 100644 --- a/gui/src/components/search/input/InputRange.js +++ b/gui/src/components/search/input/InputRange.js @@ -222,7 +222,7 @@ export const Range = React.memo(({ return undefined } const rangeSI = maxLocal - minLocal - const range = new Quantity(rangeSI, unitStorage).to(xAxis.unit).value() + const range = new Quantity(rangeSI, unitStorage.toDelta()).to(xAxis.unit).value() const intervalCustom = getInterval(range, nSteps, xAxis.dtype) return new Quantity(intervalCustom, xAxis.unit).to(unitStorage).value() }, [maxLocal, minLocal, discretization, nSteps, xAxis.dtype, xAxis.unit, unitStorage]) diff --git a/gui/src/components/units/Quantity.js b/gui/src/components/units/Quantity.js index 4926ffac61d2db5651f833bf38f5d3cb7fabb6ed..63c66a31918617b8b14d4083983523436a13aa89 100644 --- a/gui/src/components/units/Quantity.js +++ b/gui/src/components/units/Quantity.js @@ -17,8 +17,7 @@ */ import {isNumber, isArray} from 'lodash' -import {Unit, normalizeExpression} from './Unit' -import { Unit as UnitMathJS } from 'mathjs' +import {Unit} from './Unit' import {mapDeep} from '../../utils' /** @@ -36,14 +35,14 @@ export class Quantity { constructor(value, unit, normalized = false) { this.unit = new Unit(unit) if (!isNumber(value) && !isArray(value)) { - throw Error('Please provide the value as a number, or as a multidimensional array of numbers.') + throw Error('Please provide the value for a Quantity as a number, or as a multidimensional array of numbers.') } // This attribute stores the quantity value in 'normalized' form that is // given in the base units (=SI). This value should only be determined once - // during the unit initialization and all calls to value() will then lazily - // determine the value in the currently set units. This avoids 'drift' in - // the value caused by several consecutive changes of the units. + // during initialization and all calls to value() will then lazily determine + // the value in the currently set units. This avoids 'drift' in the value + // caused by several consecutive changes of the units. this.normalized_value = normalized ? value : this.normalize(value) } @@ -60,8 +59,29 @@ export class Quantity { * @param {n-dimensional array} value Value in currently set units. * @returns Value in base units. */ - normalize(value) { - return mapDeep(value, (x) => this.unit.mathjsUnit._normalize(x)) + normalize(values) { + // Pre-calculate coefficients for currently set units. This speeds up + // conversion for large arrays. + const unit = this.unit.mathjsUnit + const ignoreOffset = unit._isDerived() + const coefficients = this.conversion_coefficients() + + return mapDeep(values, (value) => { + if (value === null || value === undefined) { + return value + } + + let result = value + for (let i = 0; i < unit.units.length; i++) { + const unitDef = unit.units[i] + const unitOffset = unitDef.unit.offset + const variable = (ignoreOffset || unitDef.delta) + ? result + : result + unitOffset + result = variable * coefficients[i] + } + return result + }) } /** @@ -69,8 +89,45 @@ export class Quantity { * @param {n-dimensional array} value Value in base units. * @returns Value in currently set units. */ - denormalize(value) { - return mapDeep(value, (x) => this.unit.mathjsUnit._denormalize(x)) + denormalize(values) { + // Pre-calculate coefficients for currently set units. This speeds up + // conversion for large arrays. + const unit = this.unit.mathjsUnit + const ignoreOffset = unit._isDerived() + const coefficients = this.conversion_coefficients() + + return mapDeep(values, (value) => { + if (value === null || value === undefined) { + return value + } + + let result = value + for (let i = 0; i < unit.units.length; i++) { + const unitDef = unit.units[i] + const unitOffset = unitDef.unit.offset + result = (ignoreOffset || unitDef.delta) + ? result / coefficients[i] + : result / coefficients[i] - unitOffset + } + return result + }) + } + + /** + * Returns a set of conversion coefficients based on the currently set units. + * @returns Array of conversion coefficients, one for each unit that is present. + */ + conversion_coefficients() { + const unit = this.unit.mathjsUnit + const coefficients = [] + for (let i = 0; i < unit.units.length; i++) { + const unitDef = unit.units[i] + const unitValue = unitDef.unit.value + const unitPrefixValue = unitDef.prefix.value + const unitPower = unitDef.power + coefficients.push((Math.pow(unitValue * unitPrefixValue, unitPower))) + } + return coefficients } label() { @@ -106,83 +163,3 @@ export class Quantity { } } } - -/** - * Convenience function for parsing value and unit information from a string. - * - * @param {string} input The input string to parse - * @param {string} dimension Dimension for the unit. Note that you should use - * the base dimensions which you can get e.g. with .dimension(true). Defaults - * to 'dimensionless' if not specified. If you want to disable dimension - * checks, use null. - * @param {boolean} requireValue Whether an explicit numeric value is required at the start of the input. - * @param {boolean} requireUnit Whether an explicit unit in the input is required at the end of the input. - * @returns Object containing the following properties, if available: - * - value: Numerical value as a number - * - valueString: The original number input as a string. Note that this can only return - * the number when it is used as a prefix, and does not work with numbers that are - * part of a complex expression, e.g. 300 eV / 1000 K. - * - unit: Unit instance - * - error: Error messsage - */ -export function parseQuantity(input, dimension = 'dimensionless', requireValue = false, requireUnit = false) { - input = input.trim() - let error - let value - let valueString = input.match(/^[+-]?((\d+\.\d+|\d+\.|\.\d?|\d+)(e|e\+|e-)\d+|(\d+\.\d+|\d+\.|\.\d?|\d+))?/)?.[0] - const unitString = input.substring(valueString.length)?.trim() || '' - - // Check value if required - if (valueString === '') { - valueString = undefined - value = undefined - if (requireValue) { - error = 'Enter a valid numerical value.' - } - } else { - value = Number(valueString) - } - - // Check unit if required - if (requireUnit) { - if (unitString === '') { - return {valueString, value, error: 'Unit is required.'} - } - } - - // Try to parse with MathJS: it can extract the unit even when it is mixed - // with numbers - input = normalizeExpression(input) - let unitMathJS - try { - unitMathJS = UnitMathJS.parse(input, {allowNoUnits: true}) - } catch (e) { - return {valueString, error: e.message} - } - - let unit - unitMathJS.value = null - try { - unit = new Unit(unitMathJS) - } catch (e) { - error = e.msg - } - if (error) { - return {valueString, value, unit, error} - } - - // If unit is not required and it is dimensionless, return without new unit - if (!requireUnit && unit.dimension() === 'dimensionless') { - return {valueString, value} - } - - // TODO: This check is not enough: the input may be compatible after the base - // units are compared. - if (dimension !== null) { - if (!(unit.dimension(true) === dimension || unit.dimension(false) === dimension)) { - error = `Unit "${unit.label(false)}" is incompatible with dimension "${dimension}".` - } - } - - return {value, valueString, unit, error} -} diff --git a/gui/src/components/units/Quantity.spec.js b/gui/src/components/units/Quantity.spec.js index 8179c40a9f212118887ede8c054a7e6c384b3b1a..cd278f551c83904cef2d91b0399bfe5062d9105d 100644 --- a/gui/src/components/units/Quantity.spec.js +++ b/gui/src/components/units/Quantity.spec.js @@ -16,8 +16,7 @@ * limitations under the License. */ -import { Unit } from './Unit' -import { Quantity, parseQuantity } from './Quantity' +import { Quantity } from './Quantity' import { dimensionMap } from './UnitContext' test('conversion works both ways for each compatible unit', async () => { @@ -48,11 +47,13 @@ test.each([ ['division', 'm/s', 'angstrom/femtosecond', 1, 0.00001], ['multiplication', 'm*s', 'angstrom*femtosecond', 1, 9.999999999999999e+24], ['power with hat', 'm^2', 'angstrom^2', 1, 99999999999999980000], + ['power with unit that has offset', 'celsius**2', 'fahrenheit**2', 3, 9.72], // The units here automatically become delta units due to multiplication. ['power with double asterisk (single)', 'm**2', 'angstrom**2', 1, 99999999999999980000], ['power with double asterisk (multiple)', 'm**2 / s**2', 'angstrom**2 / ms**2', 1, 99999999999999.98], - ['explicit delta (single)', 'delta_celsius', 'delta_K', 1, 274.15], + ['explicit delta identity (single)', 'delta_celsius', 'delta_celsius', 1, 1], + ['explicit delta (single)', 'delta_celsius', 'delta_K', 1, 1], ['explicit delta (multiple)', 'delta_celsius / delta_celsius', 'delta_K / delta_K', 1, 1], - ['explicit delta symbol (single)', 'Δcelsius', 'ΔK', 1, 274.15], + ['explicit delta symbol (single)', 'Δcelsius', 'ΔK', 1, 1], ['explicit delta symbol (multiple)', 'Δcelsius / Δcelsius', 'ΔK / ΔK', 1, 1], ['combined', 'm*m/s^2', 'angstrom^2/femtosecond^2', 1, 9.999999999999999e-11], ['negative exponent', 's^-2', 'femtosecond^-2', 1, 1e-30], @@ -76,7 +77,10 @@ test.each([ ['combination', 'a_u_force * angstrom', {force: {definition: 'newton'}, length: {definition: 'meter'}}, 1, 8.23872349823899e-18], ['use base units if derived unit not defined in system', 'newton * meter', {mass: {definition: 'kilogram'}, time: {definition: 'second'}, length: {definition: 'meter'}}, 1, 1], ['unit definition with prefix', 'kg^2', {mass: {definition: 'mg'}}, 1, 1e12], - ['expression as definition', 'N', {force: {definition: '(kg m) / s^2'}}, 1, 1] + ['expression as definition', 'N', {force: {definition: '(kg m) / s^2'}}, 1, 1], + ['delta inherited for single base unit', 'delta_celsius', {temperature: {definition: 'K'}}, 1, 1], + ['delta inherited for derived unit', 'delta_newton', {force: {definition: 'mN'}}, 1, 1000], + ['delta inherited when transforming to base units', 'delta_newton', {mass: {definition: 'kilogram'}, time: {definition: 'second'}, length: {definition: 'meter'}}, 1, 1] ] )('test conversion with "toSystem()": %s', async (name, unit, system, valueA, valueB) => { const a = new Quantity(valueA, unit) @@ -122,23 +126,3 @@ test.each([ } expect(valueA).toBeCloseTo(10 * valueB) }) - -test.each([ - ['number only', '100', undefined, true, false, {valueString: '100', value: 100}], - ['unit only', 'joule', null, false, true, {valueString: undefined, value: undefined, unit: new Unit('joule')}], - ['number and unit with dimension', '100 joule', 'energy', true, true, {valueString: '100', value: 100, unit: new Unit('joule')}], - ['number and unit without dimension', '100 joule', null, true, true, {valueString: '100', value: 100, unit: new Unit('joule')}], - ['incorrect dimension', '100 joule', 'length', true, true, {valueString: '100', value: 100, unit: new Unit('joule'), error: 'Unit "joule" is incompatible with dimension "length".'}], - ['missing unit', '100', 'length', true, true, {valueString: '100', value: 100, unit: undefined, error: 'Unit is required.'}], - ['missing value', 'joule', 'energy', true, true, {valueString: undefined, value: undefined, unit: new Unit('joule'), error: 'Enter a valid numerical value.'}], - ['mixing number and quantity #1', '1 / joule', 'energy^-1', false, false, {valueString: '1', value: 1, unit: new Unit('1 / joule')}], - ['mixing number and quantity #2', '100 / joule', 'energy^-1', false, false, {valueString: '100', value: 100, unit: new Unit('1 / joule')}] - -] -)('test parseQuantity: %s', async (name, input, dimension, requireValue, requireUnit, expected) => { - const result = parseQuantity(input, dimension, requireValue, requireUnit) - expect(result.valueString === expected.valueString).toBe(true) - expect(result.value === expected.value).toBe(true) - expect(result.unit?.label() === expected.unit?.label()).toBe(true) - expect(result.error === expected.error).toBe(true) -}) diff --git a/gui/src/components/units/Unit.js b/gui/src/components/units/Unit.js index 220ac7bc8e3aeb3437b357abfb47510ebb07149c..58ab8e6810c21d5483b771c3ec3fc8eea2c9e087 100644 --- a/gui/src/components/units/Unit.js +++ b/gui/src/components/units/Unit.js @@ -18,6 +18,7 @@ import {isNil, has, isString} from 'lodash' import {Unit as UnitMathJS} from 'mathjs' import {unitToAbbreviationMap} from './UnitContext' +import {parseInternal} from './common' export const DIMENSIONLESS = 'dimensionless' @@ -35,8 +36,7 @@ export class Unit { */ constructor(unit) { if (isString(unit)) { - unit = normalizeExpression(unit) - unit = new UnitMathJS(undefined, unit) + unit = parseInternal(unit, {requireUnit: true}).unit } else if (unit instanceof Unit) { unit = unit.mathjsUnit.clone() } else if (unit instanceof UnitMathJS) { @@ -45,8 +45,6 @@ export class Unit { throw Error('Please provide the unit as a string or as an instance of Unit.') } this.mathjsUnit = unit - // this._labelabbreviate = undefined - // this._label = undefined } /** @@ -56,7 +54,6 @@ export class Unit { */ equalBase(unit) { if (isString(unit)) { - unit = normalizeExpression(unit) unit = new Unit(unit) } return this.mathjsUnit.equalBase(unit.mathjsUnit) @@ -70,19 +67,22 @@ export class Unit { * (as given or defined by the unit system) are used. * @returns A string representing the unit. */ - label(abbreviate = true) { + label(abbreviate = true, showDelta = false) { // TODO: The label caching is disabled for now. Because Quantities are // stored as recoil.js atoms, they become immutable which causes problems // with internal state mutation. - // if (this._labelabbreviate === abbreviate && this._label) { - // return this._label - // } const units = this.mathjsUnit.units let strNum = '' let strDen = '' let nNum = 0 let nDen = 0 + function getDelta(unit) { + return (showDelta && unit.delta) + ? abbreviate ? 'Δ' : 'delta_' + : '' + } + function getName(unit) { if (unit.base.key === DIMENSIONLESS) return '' return abbreviate @@ -90,7 +90,7 @@ export class Unit { : unit.name } - function getPrefix(unit, original) { + function getPrefix(original) { if (!abbreviate) return original const prefixMap = { // SI @@ -128,32 +128,36 @@ export class Unit { } for (let i = 0; i < units.length; i++) { - if (units[i].power > 0) { + const unitDef = units[i] + if (unitDef.power > 0) { nNum++ - const prefix = getPrefix(units[i].unit.name, units[i].prefix.name) - const name = getName(units[i].unit) - strNum += ` ${prefix}${name}` - if (Math.abs(units[i].power - 1.0) > 1e-15) { - strNum += '^' + units[i].power + const prefix = getPrefix(unitDef.prefix.name) + const name = getName(unitDef.unit) + const delta = getDelta(unitDef) + strNum += ` ${delta}${prefix}${name}` + if (Math.abs(unitDef.power - 1.0) > 1e-15) { + strNum += '^' + unitDef.power } - } else if (units[i].power < 0) { + } else if (unitDef.power < 0) { nDen++ } } if (nDen > 0) { for (let i = 0; i < units.length; i++) { - if (units[i].power < 0) { - const prefix = getPrefix(units[i].unit.name, units[i].prefix.name) - const name = getName(units[i].unit) + const unitDef = units[i] + if (unitDef.power < 0) { + const prefix = getPrefix(unitDef.prefix.name) + const name = getName(unitDef.unit) + const delta = getDelta(unitDef) if (nNum > 0) { - strDen += ` ${prefix}${name}` - if (Math.abs(units[i].power + 1.0) > 1e-15) { - strDen += '^' + (-units[i].power) + strDen += ` ${delta}${prefix}${name}` + if (Math.abs(unitDef.power + 1.0) > 1e-15) { + strDen += '^' + (-unitDef.power) } } else { - strDen += ` ${prefix}${name}` - strDen += '^' + (units[i].power) + strDen += ` ${delta}${prefix}${name}` + strDen += '^' + (unitDef.power) } } } @@ -177,8 +181,6 @@ export class Unit { } str += strDen - // this._labelabbreviate = abbreviate - // this._label = str return str } @@ -223,20 +225,28 @@ export class Unit { * @returns A new Unit expressed in the given units. */ to(unit) { - if (isString(unit)) { - unit = normalizeExpression(unit) - } else if (unit instanceof Unit) { - unit = unit.label() + let mathjsUnit + if (unit instanceof Unit) { + mathjsUnit = unit.mathjsUnit + } else if (isString(unit)) { + mathjsUnit = parseInternal(unit, {requireUnit: true}).unit } else { throw Error('Unknown unit type. Please provide the unit as as string or as instance of Unit.') } + return new Unit(this.mathjsUnit.to(mathjsUnit)) + } - // We cannot directly feed the unit string into Math.js, because it will try - // to parse units like 1/<unit> as Math.js units which have values, and then - // will raise an exception when converting between valueless and valued - // unit. The workaround is to explicitly define a valueless unit. - unit = new UnitMathJS(undefined, unit === '' ? DIMENSIONLESS : unit) - return new Unit(this.mathjsUnit.to(unit)) + /** + * Converts all units to their delta versions. + * + * @returns A new Unit with delta units. + */ + toDelta() { + const unitMathJS = this.mathjsUnit.clone() + for (const unit of unitMathJS.units) { + unit.delta = true + } + return new Unit(unitMathJS) } /** @@ -295,6 +305,9 @@ export class Unit { * present, it will, however, attempt to convert it to the base units. Any * further simplication is not performed. * + * If the converted unit has any delta-units, the converted units will also + * become delta-units. + * * @param {object} system The target unit system. * @returns A new Unit instance in the given system. */ @@ -310,11 +323,12 @@ export class Unit { for (const unit of this.mathjsUnit.units) { const dimension = unit.unit.base.key const newUnitDefinition = system?.[dimension]?.definition + // If the unit for this dimension is defined, use it if (!isNil(newUnitDefinition)) { const newUnit = new Unit(newUnitDefinition) for (const unitDef of newUnit.mathjsUnit.units) { - proposedUnitList.push({...unitDef, power: unitDef.power * unit.power}) + proposedUnitList.push({...unitDef, power: unitDef.power * unit.power, delta: unit.delta}) } // Otherwise convert to base units } else { @@ -328,7 +342,8 @@ export class Unit { proposedUnitList.push({ unit: UNITS[system[baseDim].definition], prefix: PREFIXES.NONE[''], - power: unit.power ? newDimensions[i] * unit.power : 0 + power: unit.power ? newDimensions[i] * unit.power : 0, + delta: unit.delta }) } else { missingBaseDim = true @@ -346,22 +361,3 @@ export class Unit { return new Unit(ret) } } - -/** - * Normalizes the given expression into a format that can be parsed by MathJS. - * - * This function will replace the Pint power symbol of '**' with the symbol - * '^' used by MathJS. In addition, we convert any 'delta'-units (see: - * https://pint.readthedocs.io/en/stable/nonmult.html) into their regular - * counterparts: MathJS will automatically ignore the offset when using - * non-multiplicative units in expressions. - * - * @param {str} expression Expression - * @returns string Expression in normalized form - */ -export function normalizeExpression(expression) { - let normalized = expression.replace(/\*\*/g, '^') - normalized = normalized.replace(/delta_/g, '') - normalized = normalized.replace(/Δ/g, '') - return normalized -} diff --git a/gui/src/components/units/Unit.spec.js b/gui/src/components/units/Unit.spec.js index 7cd88133bc700fbb8a531c4b5726dbc17959d7c9..b79b0868e7598e3f8c3a32ea4dd84188e1d3bbc1 100644 --- a/gui/src/components/units/Unit.spec.js +++ b/gui/src/components/units/Unit.spec.js @@ -55,11 +55,14 @@ test.each([ ['preserve order', 'second*meter', 's m'], ['power', 'meter^2', 'm^2'], ['negative power', 'meter^-1', 'm^-1'], - ['chain', 'meter*meter/second^2', '(m m) / s^2'] + ['chain', 'meter*meter/second^2', '(m m) / s^2'], + ['delta long', 'delta_celsius', 'Δ°C'], + ['delta short', 'Δcelsius', 'Δ°C'], + ['delta with prefix', 'delta_millicelsius', 'Δm°C'] ] )('label abbreviation: %s', async (name, unit, label) => { const a = new Unit(unit) - expect(a.label()).toBe(label) + expect(a.label(true, true)).toBe(label) }) test.each([ @@ -87,10 +90,10 @@ test.each([ ['power with hat', 'm^2', 'angstrom^2', 'Å^2'], ['power with double asterisk (single)', 'm**2', 'angstrom**2', 'Å^2'], ['power with double asterisk (multiple)', 'm**2 / s**2', 'angstrom**2 / ms**2', 'Å^2 / ms^2'], - ['explicit delta (single)', 'delta_celsius', 'delta_K', 'K'], - ['explicit delta (multiple)', 'delta_celsius / delta_celsius', 'delta_K / delta_K', 'K / K'], - ['explicit delta symbol (single)', 'Δcelsius', 'ΔK', 'K'], - ['explicit delta symbol (multiple)', 'Δcelsius / Δcelsius', 'ΔK / ΔK', 'K / K'], + ['explicit delta (single)', 'delta_celsius', 'delta_K', 'ΔK'], + ['explicit delta (multiple)', 'delta_celsius / delta_celsius', 'delta_K / delta_K', 'ΔK / ΔK'], + ['explicit delta symbol (single)', 'Δcelsius', 'K', 'K'], + ['explicit delta symbol (multiple)', 'Δcelsius / Δcelsius', 'ΔK / ΔK', 'ΔK / ΔK'], ['combined', 'm*m/s^2', 'angstrom^2/femtosecond^2', 'Å^2 / fs^2'], ['negative exponent', 's^-2', 'femtosecond^-2', 'fs^-2'], ['simple to complex with one unit', 'N', 'kg*m/s^2', '(kg m) / s^2'], @@ -102,7 +105,7 @@ test.each([ )('test conversion with "to()": %s', async (name, unitA, unitB, labelB) => { const a = new Unit(unitA) const b = a.to(unitB) - expect(b.label()).toBe(labelB) + expect(b.label(true, true)).toBe(labelB) }) test.each([ @@ -113,12 +116,15 @@ test.each([ ['combination', 'a_u_force * angstrom', {force: {definition: 'newton'}, length: {definition: 'meter'}}, 'N m'], ['use base units if derived unit not defined in system', 'newton * meter', {mass: {definition: 'kilogram'}, time: {definition: 'second'}, length: {definition: 'meter'}}, '(kg m m) / s^2'], ['unit definition with prefix', 'kg^2', {mass: {definition: 'mg'}}, 'mg^2'], - ['expression as definition', 'N', {force: {definition: '(kg m) / s^2'}}, '(kg m) / s^2'] + ['expression as definition', 'N', {force: {definition: '(kg m) / s^2'}}, '(kg m) / s^2'], + ['delta inherited for single base unit', 'delta_celsius', {temperature: {definition: 'K'}}, 'ΔK'], + ['delta inherited for derived unit', 'delta_newton', {force: {definition: 'mN'}}, 'ΔmN'], + ['delta inherited when transforming to base units', 'delta_newton', {mass: {definition: 'kilogram'}, time: {definition: 'second'}, length: {definition: 'meter'}}, '(Δkg Δm) / Δs^2'] ] )('test conversion with "toSystem()": %s', async (name, unit, system, label) => { const a = new Unit(unit) const b = a.toSystem(system) - expect(b.label()).toBe(label) + expect(b.label(true, true)).toBe(label) }) test.each([ diff --git a/gui/src/components/units/UnitContext.js b/gui/src/components/units/UnitContext.js index f31b3a5cb24e27cb979953905108f4b43fa5bd8b..f946f6158d8b592e513bf77ceb81c79fb2813164 100644 --- a/gui/src/components/units/UnitContext.js +++ b/gui/src/components/units/UnitContext.js @@ -39,7 +39,7 @@ UnitMathJS.PREFIXES.PINT = prefixes // Customize the unit parsing to allow certain special symbols const isAlphaOriginal = UnitMathJS.isValidAlpha const isSpecialChar = function(c) { - const specialChars = new Set(['_', 'Å', 'Å', 'å', '°', 'µ', 'ö', 'é', '∞']) + const specialChars = new Set(['_', 'Å', 'Å', 'å', '°', 'µ', 'ö', 'é', '∞', 'Δ']) return specialChars.has(c) } const isGreekLetter = function(c) { @@ -50,7 +50,7 @@ UnitMathJS.isValidAlpha = function(c) { return isAlphaOriginal(c) || isSpecialChar(c) || isGreekLetter(c) } -// Create MathJS unit definitions from the data exported by 'nomad dev units' +// Create MathJS unit definitions from the data exported by 'nomad dev units'. export const unitToAbbreviationMap = {} const unitDefinitions = {} for (let def of unitList) { diff --git a/gui/src/components/units/UnitInput.js b/gui/src/components/units/UnitInput.js index bddff7a034e939ae3b58ce033a27d25297e163d7..327fe3d97b012c852cec2b791fa5bf368ae2affc 100644 --- a/gui/src/components/units/UnitInput.js +++ b/gui/src/components/units/UnitInput.js @@ -21,7 +21,7 @@ import PropTypes from 'prop-types' import {isNil} from 'lodash' import {getSuggestions} from '../../utils' import {unitMap} from './UnitContext' -import {parseQuantity} from './Quantity' +import {parse} from './common' import {List, ListItemText, ListSubheader, makeStyles} from '@material-ui/core' import {VariableSizeList} from 'react-window' import {InputText} from '../search/input/InputText' @@ -80,7 +80,7 @@ export const UnitInput = React.memo(({value, error, onChange, onAccept, onSelect return {valid: false, error: 'Please specify a value'} } } - const {error, unit} = parseQuantity(value, dimension, false, true) + const {error, unit} = parse(value, {dimension, requireUnit: true}) return {valid: !error, error, data: unit} }, [optional, dimension]) diff --git a/gui/src/components/units/common.js b/gui/src/components/units/common.js new file mode 100644 index 0000000000000000000000000000000000000000..e0a6e46b56c29c2c0688862b54421b2d4ec152ce --- /dev/null +++ b/gui/src/components/units/common.js @@ -0,0 +1,444 @@ +/* + * Copyright The NOMAD Authors. + * + * This file is part of NOMAD. See https://nomad-lab.eu for further info. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {memoize, has} from 'lodash' +import {Unit} from './Unit' +import { Unit as UnitMathJS } from 'mathjs' + +export const deltaPrefixes = ['delta_', 'Δ'] + +/** + * Convenience function for parsing value and unit information from a string. + * Can parse values, units or both at the same time. + * + * @param {string} input The input string to parse + * @param {object} options Parsing options. These include: + * - dimension: Dimension for the unit. Note that you should use the base + * dimensions which you can get e.g. with .dimension(true). + * - requireValue: Whether an explicit numeric value is required at the start + * of the input. + * - requireUnit: Whether an explicit unit in the input is required. + * @returns Object containing the following properties, if available: + * - value: Numerical value as a number + * - valueString: The original number input as a string. Note that this can only return + * the number when it is used as a prefix, and does not work with numbers + * that are part of a complex expression, e.g. 300 eV / 1000 K. + * - unit: Unit instance + * - error: Error messsage + */ +export function parse(input, options) { + options = { + dimension: null, + requireValue: false, + requireUnit: false, + ...options + } || {} + + const result = {} + + try { + const parseResults = parseInternal(input, options) + result.value = parseResults.value || undefined + result.valueString = parseResults.valueString || undefined + if (parseResults.unit?.units?.length) { + result.unit = new Unit(parseResults.unit) + } + } catch (e) { + result.error = e.message + return result + } + + // TODO: This check is not enough: the input may be compatible after the base + // units are compared. + if (options.dimension !== null && result.unit) { + if (!(result.unit.dimension(true) === options.dimension || result.unit.dimension(false) === options.dimension)) { + result.error = `Unit "${result.unit.label(false)}" is incompatible with dimension "${options.dimension}".` + } + } + + return result +} + +/** + * Parse a string into an optional value and a MathJS Unit definition. + * + * Throws an exception if the provided string does not contain a valid unit or + * cannot be parsed. + * + * @memberof Unit + * @param {string} str A string like "5.2 inch", "4e2 cm/s^2" + * @param {object} options Parsing options. These include: + * - requireValue: Whether an explicit numeric value is required at the start + * of the input. + * - requireUnit: Whether an explicit unit in the input is required. + * @returns Object containing the following properties, if available: + * - value: Numerical value as a number + * - valueString: The original number input as a string. Note that this can only return + * the number when it is used as a prefix, and does not work with numbers + * that are part of a complex expression, e.g. 300 eV / 1000 K. + * - unit: Unit instance + */ +export function parseInternal(str, options) { + // Replace ** with ^ + str = str.replace(/\*\*/g, '^') + + options = options || {} + const text = str + let index = -1 + let c = '' + + function skipWhitespace() { + while (c === ' ' || c === '\t') { + next() + } + } + + function isDigitDot(c) { + return ((c >= '0' && c <= '9') || c === '.') + } + + function isDigit(c) { + return ((c >= '0' && c <= '9')) + } + + function next() { + index++ + c = text.charAt(index) + } + + function revert(oldIndex) { + index = oldIndex + c = text.charAt(index) + } + + function parseUnit() { + let unitName = '' + + // Alphanumeric characters only; matches [a-zA-Z0-9] + while (isDigit(c) || UnitMathJS.isValidAlpha(c)) { + unitName += c + next() + } + + // Must begin with [a-zA-Z] + const firstC = unitName.charAt(0) + if (UnitMathJS.isValidAlpha(firstC)) { + return unitName + } else { + return null + } + } + + function parseCharacter(toFind) { + if (c === toFind) { + next() + return toFind + } else { + return null + } + } + + function parseNumber() { + let number = '' + const oldIndex = index + + if (c === '+') { + next() + } else if (c === '-') { + number += c + next() + } + + if (!isDigitDot(c)) { + // a + or - must be followed by a digit + revert(oldIndex) + return null + } + + // get number, can have a single dot + if (c === '.') { + number += c + next() + if (!isDigit(c)) { + // this is no legal number, it is just a dot + revert(oldIndex) + return null + } + } else { + while (isDigit(c)) { + number += c + next() + } + if (c === '.') { + number += c + next() + } + } + while (isDigit(c)) { + number += c + next() + } + + // check for exponential notation like "2.3e-4" or "1.23e50" + if (c === 'E' || c === 'e') { + // The grammar branches here. This could either be part of an exponent or the start of a unit that begins with the letter e, such as "4exabytes" + + let tentativeNumber = '' + const tentativeIndex = index + + tentativeNumber += c + next() + + if (c === '+' || c === '-') { + tentativeNumber += c + next() + } + + // Scientific notation MUST be followed by an exponent (otherwise we assume it is not scientific notation) + if (!isDigit(c)) { + // The e or E must belong to something else, so return the number without the e or E. + revert(tentativeIndex) + return number + } + + // We can now safely say that this is scientific notation. + number = number + tentativeNumber + while (isDigit(c)) { + number += c + next() + } + } + + return number + } + + if (typeof text !== 'string') { + throw new TypeError('Invalid argument in Unit.parse, string expected') + } + + const unit = new UnitMathJS() + unit.units = [] + + let powerMultiplierCurrent = 1 + let expectingUnit = false + + // A unit should follow this pattern: + // [number] ...[ [*/] unit[^number] ] + // unit[^number] ... [ [*/] unit[^number] ] + + // Rules: + // number is any floating point number. + // unit is any alphanumeric string beginning with an alpha. Units with names like e3 should be avoided because they look like the exponent of a floating point number! + // The string may optionally begin with a number. + // Each unit may optionally be followed by ^number. + // Whitespace or a forward slash is recommended between consecutive units, although the following technically is parseable: + // 2m^2kg/s^2 + // it is not good form. If a unit starts with e, then it could be confused as a floating point number: + // 4erg + + next() + skipWhitespace() + + // Optional number at the start of the string + const valueString = parseNumber() + let value = null + if (valueString) { + value = parseFloat(valueString) + skipWhitespace() // Whitespace is not required here + + // handle multiplication or division right after the value, like '1/s' + if (parseCharacter('*')) { + powerMultiplierCurrent = 1 + expectingUnit = true + } else if (parseCharacter('/')) { + powerMultiplierCurrent = -1 + expectingUnit = true + } + } else if (options.requireValue) { + throw new SyntaxError('Enter a valid numerical value') + } + + // Stack to keep track of powerMultipliers applied to each parentheses group + const powerMultiplierStack = [] + + // Running product of all elements in powerMultiplierStack + let powerMultiplierStackProduct = 1 + + while (true) { + skipWhitespace() + + // Check for and consume opening parentheses, pushing powerMultiplierCurrent to the stack + // A '(' will always appear directly before a unit. + while (c === '(') { + powerMultiplierStack.push(powerMultiplierCurrent) + powerMultiplierStackProduct *= powerMultiplierCurrent + powerMultiplierCurrent = 1 + next() + skipWhitespace() + } + + // Is there something here? + let uStr + if (c) { + const oldC = c + uStr = parseUnit() + if (uStr === null) { + throw new SyntaxError('Unexpected "' + oldC + '" in "' + text + '" at index ' + index.toString()) + } + } else { + // End of input. + break + } + + // Verify the unit exists and get the prefix (if any) + const res = findUnit(uStr) + if (res === null) { + // Unit not found. + throw new SyntaxError('Unit "' + uStr + '" not found.') + } + + let power = powerMultiplierCurrent * powerMultiplierStackProduct + // Is there a "^ number"? + skipWhitespace() + if (parseCharacter('^')) { + skipWhitespace() + const p = parseNumber() + if (p === null) { + // No valid number found for the power! + throw new SyntaxError('In "' + str + '", "^" must be followed by a floating-point number') + } + power *= p + } + + // Add the unit to the list + unit.units.push({ + unit: res.unit, + prefix: res.prefix, + delta: res.delta, + power + }) + for (let i = 0; i < UnitMathJS.BASE_DIMENSIONS.length; i++) { + unit.dimensions[i] += (res.unit.dimensions[i] || 0) * power + } + + // Check for and consume closing parentheses, popping from the stack. + // A ')' will always follow a unit. + skipWhitespace() + while (c === ')') { + if (powerMultiplierStack.length === 0) { + throw new SyntaxError('Unmatched ")" in "' + text + '" at index ' + index.toString()) + } + powerMultiplierStackProduct /= powerMultiplierStack.pop() + next() + skipWhitespace() + } + + // "*" and "/" should mean we are expecting something to come next. + // Is there a forward slash? If so, negate powerMultiplierCurrent. The next unit or paren group is in the denominator. + expectingUnit = false + + if (parseCharacter('*')) { + // explicit multiplication + powerMultiplierCurrent = 1 + expectingUnit = true + } else if (parseCharacter('/')) { + // division + powerMultiplierCurrent = -1 + expectingUnit = true + } else { + // implicit multiplication + powerMultiplierCurrent = 1 + } + + // Replace the unit into the auto unit system + if (res.unit.base) { + const baseDim = res.unit.base.key + UnitMathJS.UNIT_SYSTEMS.auto[baseDim] = { + unit: res.unit, + prefix: res.prefix + } + } + } + + // Has the string been entirely consumed? + skipWhitespace() + if (c) { + throw new SyntaxError('Could not parse: "' + str + '"') + } + + // Is there a trailing slash? + if (expectingUnit) { + throw new SyntaxError('Trailing characters: "' + str + '"') + } + + // Is the parentheses stack empty? + if (powerMultiplierStack.length !== 0) { + throw new SyntaxError('Unmatched "(" in "' + text + '"') + } + + // Are there any units at all? + if (unit.units.length === 0 && options.requireUnit) { + throw new SyntaxError('Unit is required') + } + + return {value, valueString, unit} +} + +/** + * Find a unit from a string + * + * @param {string} str A string like 'cm' or 'inch' + * @returns {Object | null} When found, an object with fields unit and +* prefix is returned. Else, null is returned. + */ +const findUnit = memoize((str) => { + // First, match units names exactly. For example, a user could define 'mm' as + // 10^-4 m, which is silly, but then we would want 'mm' to match the + // user-defined unit. + if (has(UnitMathJS.UNITS, str)) { + const unit = UnitMathJS.UNITS[str] + const prefix = unit.prefixes[''] + return { unit, prefix, delta: false } + } + + for (const name in UnitMathJS.UNITS) { + if (has(UnitMathJS.UNITS, name)) { + if (str.endsWith(name)) { + const unit = UnitMathJS.UNITS[name] + const prefixLen = (str.length - name.length) + let prefixName = str.substring(0, prefixLen) + let delta = false + for (const deltaPrefix of deltaPrefixes) { + if (prefixName.startsWith(deltaPrefix)) { + prefixName = prefixName.substring(deltaPrefix.length) + delta = true + break + } + } + const prefix = has(unit.prefixes, prefixName) + ? unit.prefixes[prefixName] + : undefined + if (prefix !== undefined) { + return { unit, prefix, delta } + } + } + } + } + + return null +}) diff --git a/gui/src/components/units/common.spec.js b/gui/src/components/units/common.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..70c34826a5404e2df535533023238257f5d6892d --- /dev/null +++ b/gui/src/components/units/common.spec.js @@ -0,0 +1,44 @@ +/* + * Copyright The NOMAD Authors. + * + * This file is part of NOMAD. See https://nomad-lab.eu for further info. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Unit } from './Unit' +import { parse } from './common' + +test.each([ + ['number only', '100', {}, {valueString: '100', value: 100}], + ['complicated number 1', '-0.015e-10', {}, {valueString: '-0.015e-10', value: -0.015e-10}], + ['complicated number 2', '-.015E-10', {}, {valueString: '-.015E-10', value: -0.015e-10}], + ['unit only', 'joule', {}, {valueString: undefined, value: undefined, unit: new Unit('joule')}], + ['whitespaces', ' 100 joule ', {}, {valueString: '100', value: 100, unit: new Unit('joule') }], + ['number and unit with dimension', '100 joule', {}, {valueString: '100', value: 100, unit: new Unit('joule')}], + ['number and unit without dimension', '100 joule', {}, {valueString: '100', value: 100, unit: new Unit('joule')}], + ['incorrect dimension', '100 joule', {dimension: 'length'}, {valueString: '100', value: 100, unit: new Unit('joule'), error: 'Unit "joule" is incompatible with dimension "length".'}], + ['missing unit', '100', {requireUnit: true}, {error: 'Unit is required'}], + ['missing unit with dimension specified', '100', {dimension: 'energy', requireUnit: true}, {error: 'Unit is required'}], + ['missing value', 'joule', {requireValue: true}, {error: 'Enter a valid numerical value'}], + ['mixing number and quantity #1', '1 / joule', {dimension: 'energy^-1'}, {valueString: '1', value: 1, unit: new Unit('1 / joule')}], + ['mixing number and quantity #2', '100 / joule', {dimension: 'energy^-1'}, {valueString: '100', value: 100, unit: new Unit('1 / joule')}] + +] +)('test parse: %s', async (name, input, options, expected) => { + const result = parse(input, options) + expect(result.valueString === expected.valueString).toBe(true) + expect(result.value === expected.value).toBe(true) + expect(result.unit?.label() === expected.unit?.label()).toBe(true) + expect(result.error === expected.error).toBe(true) +}) diff --git a/gui/tests/artifacts.js b/gui/tests/artifacts.js index fbf6ef833c2f1a957bb19d711322be48ba448ca4..cd1808ae2403b6250a27175e10f19c2ab4151157 100644 --- a/gui/tests/artifacts.js +++ b/gui/tests/artifacts.js @@ -96388,7 +96388,7 @@ window.nomadArtifacts = { "type_data": "float64" }, "shape": [], - "unit": "degree_Celsius / minute" + "unit": "delta_degree_Celsius / minute" }, { "m_def": "nomad.metainfo.metainfo.Quantity", diff --git a/nomad/cli/dev.py b/nomad/cli/dev.py index 13837892b2517738cde98716f2d95242da7d2da0..7c7db7a37f4e6b8d2556e0920a694ddf461634de 100644 --- a/nomad/cli/dev.py +++ b/nomad/cli/dev.py @@ -585,40 +585,4 @@ def _generate_units_json(all_metainfo) -> Tuple[Any, Any]: unit_list.sort(key=lambda x: x.get('name')) unit_list.sort(key=lambda x: 0 if x.get('definition') is None else 1) - # Go through the metainfo and check that all units are defined. Note that - # this will break if complex derived units are used in the metainfo. In - # this case they can only be validated in a GUI test. - unit_names = set() - for unit in unit_list: - unit_names.add(unit['name']) - for alias in unit.get('aliases', []): - unit_names.add(alias) - - units = set() - for package in all_metainfo.packages: - for section in package.section_definitions: - for quantity in section.quantities: - unit = quantity.unit - if unit is not None: - parts = str(unit).split() - for part in parts: - is_operator = part in {'/', '**', '*'} - is_number = True - try: - int(part) - except Exception: - is_number = False - if not is_operator and not is_number: - units.add(part) - - # Check that the defined units do not contain 'delta_' or 'Δ' in them. This is - # reserved to indicate that a quantity should be treated without offset. - # MathJS does not have explicit support for these delta-units, but instead - # uses them implicitly when non-multiplicative units appear in expressions. - for unit in unit_names: - assert 'delta_' not in unit and 'Δ' not in unit, ( - f'Invalid unit name {unit}. "delta_" and "Δ" are reserved for unit variants ' - 'with no offset, but MathJS does not have support for these units.' - ) - return unit_list, prefixes diff --git a/nomad/metainfo/metainfo.py b/nomad/metainfo/metainfo.py index 9dfb5ffcf799d94328924c87eeb8e0e5a32db9de..99b5263a09a9a09089a13c239d65f202b98e2b42 100644 --- a/nomad/metainfo/metainfo.py +++ b/nomad/metainfo/metainfo.py @@ -64,7 +64,6 @@ from nomad.metainfo.util import ( MTypes, ReferenceURL, SectionAnnotation, - _delta_symbols, check_dimensionality, check_unit, convert_to, @@ -391,14 +390,21 @@ class _Unit(DataType): if quantity_def.flexible_unit: return None - value = value.__str__() + return value.__str__() + # The delta prefixes are not serialized: only implicit deltas are # allowed currently. - return reduce(lambda a, b: a.replace(b, ''), _delta_symbols, value) + # return reduce(lambda a, b: a.replace(b, ''), _delta_symbols, value) def deserialize(self, section, quantity_def: Quantity, value): check_unit(value) - value = units.parse_units(value) + + # The serialized version has the deltas always in correct locations, so + # we skip the automatic delta conversion. This way users can also choose + # to not use delta units with ureg.parse_units('celsius/hr', + # as_delta=False) + value = units.parse_units(value, as_delta=False) + check_dimensionality(quantity_def, value) return value diff --git a/nomad/metainfo/util.py b/nomad/metainfo/util.py index cb195b8849e5abf855652fff3980cacf64e605a5..928480056b511d80313c6a1cdf3577d3bc2fc53f 100644 --- a/nomad/metainfo/util.py +++ b/nomad/metainfo/util.py @@ -49,7 +49,6 @@ except AttributeError: from nomad.units import ureg __hash_method = 'sha1' # choose from hashlib.algorithms_guaranteed -_delta_symbols = {'delta_', 'Δ'} @dataclass(frozen=True) @@ -721,18 +720,9 @@ def check_dimensionality(quantity_def, unit: Optional[pint.Unit]) -> None: def check_unit(unit: Union[str, pint.Unit]) -> None: """Check that the unit is valid.""" - if isinstance(unit, str): - unit_str = unit - elif isinstance(unit, pint.Unit): - unit_str = str(unit) - else: + if not isinstance(unit, (str, pint.Unit)): raise TypeError('Units must be given as str or pint Unit instances.') - # Explicitly providing a Pint delta-unit is not currently allowed. - # Implicit conversions are fine as MathJS on the frontend supports them. - if any(x in unit_str for x in _delta_symbols): - raise TypeError('Explicit Pint "delta"-units are not yet supported.') - def to_section_def(section_def): """ diff --git a/tests/metainfo/test_metainfo.py b/tests/metainfo/test_metainfo.py index a4f780ed1d234246ed16a2a43b55cbb8ca91db49..f4702c2528b2183573450facefd2856b47e71055 100644 --- a/tests/metainfo/test_metainfo.py +++ b/tests/metainfo/test_metainfo.py @@ -215,37 +215,58 @@ class TestM2: assert System.lattice_vectors.unit is not None @pytest.mark.parametrize( - 'unit', + 'unit, serialization', [ - pytest.param('delta_degC / hr'), - pytest.param('ΔdegC / hr'), - pytest.param(ureg.delta_degC / ureg.hour), - ], - ) - def test_unit_explicit_delta(self, unit): - """Explicit delta values are not allowed when setting or de-serializing.""" - with pytest.raises(TypeError): - Quantity(type=np.dtype(np.float64), unit=unit) - with pytest.raises(TypeError): - Quantity.m_from_dict( - {'m_def': 'nomad.metainfo.metainfo.Quantity', 'unit': str(unit)} - ) - - @pytest.mark.parametrize( - 'unit', - [ - pytest.param('degC / hr'), - pytest.param(ureg.degC / ureg.hour), + pytest.param( + 'delta_degC', + 'delta_degree_Celsius', + id='explicit delta full, non-multiplicative, string', + ), + pytest.param( + 'ΔdegC', + 'delta_degree_Celsius', + id='explicit delta short, non-multiplicative, string', + ), + pytest.param( + 'delta_degC / hr', + 'delta_degree_Celsius / hour', + id='explicit delta full, multiplicative, string', + ), + pytest.param( + 'ΔdegC / hr', + 'delta_degree_Celsius / hour', + id='explicit delta short, multiplicative, string', + ), + pytest.param( + ureg.delta_degC / ureg.hour, + 'delta_degree_Celsius / hour', + id='explicit delta, multiplicative, objects', + ), + pytest.param( + 'degC / hr', + 'delta_degree_Celsius / hour', + id='implicit delta, multiplicative, string', + ), + pytest.param('degC', 'degree_Celsius', id='no delta, non-multiplicative'), + pytest.param( + ureg.parse_units('degC / hr', as_delta=False), + 'degree_Celsius / hour', + id='no delta, multiplicative, string', + ), + pytest.param( + ureg.degC / ureg.hour, + 'degree_Celsius / hour', + id='no delta, multiplicative, objects', + ), ], ) - def test_unit_implicit_delta(self, unit): - """Implicit delta values are allowed in setting and deserializing, delta - prefixes are not serialized. - """ + def test_unit_delta(self, unit, serialization): quantity = Quantity(type=np.dtype(np.float64), unit=unit) serialized = quantity.m_to_dict() - assert serialized['unit'] == 'degree_Celsius / hour' - Quantity.m_from_dict(serialized) + deserialized = quantity.m_from_dict(serialized) + + assert serialized['unit'] == serialization + assert str(deserialized.unit) == str(quantity.unit) @pytest.mark.parametrize( 'dtype', [pytest.param(np.longlong), pytest.param(np.ulonglong)]