diff --git a/docs/develop/setup.md b/docs/develop/setup.md index 4fb94c3b8f64e711067db56369b5d8bda1895d35..2f37234b5730294b03d789fa13bffd7ea4dc8596 100644 --- a/docs/develop/setup.md +++ b/docs/develop/setup.md @@ -385,25 +385,15 @@ pytest -sv tests ``` !!! note - Some of these tests will fail because a few large files are not included in the Git - repository. You may ignore these for local testing, they are still checked by the - CI/CD pipeline: - - ```text - FAILED tests/archive/test_archive.py::test_read_springer - AttributeError: 'NoneType' object has no attribute 'seek' - FAILED tests/normalizing/test_material.py::test_material_bulk - assert None - FAILED tests/normalizing/test_system.py::test_springer_normalizer - IndexError: list index out of range - ``` - If you excluded plugins in your [NOMAD config](### `nomad.yaml`), then those tests - will also fail. + will fail. We use Ruff and Mypy to maintain code quality. Additionally, we recommend installing the Ruff [plugins](https://docs.astral.sh/ruff/integrations/) for your code editor to streamline the process. To execute Ruff and Mypy from the command line, you can utilize the following command: + ```shell nomad dev qa --skip-tests ``` - To run all tests and code QA: ```shell diff --git a/examples/data/docs/tabular-parser_3_row_current-entry_to-path.archive.yaml b/examples/data/docs/tabular-parser_3_row_current-entry_to-path.archive.yaml index 6fe345133787b42a5a262dd9fedfcdaca38c9531..db3cc7eebd1b85cd4c85313a6963a3324a2baa72 100644 --- a/examples/data/docs/tabular-parser_3_row_current-entry_to-path.archive.yaml +++ b/examples/data/docs/tabular-parser_3_row_current-entry_to-path.archive.yaml @@ -32,7 +32,7 @@ definitions: m_annotations: eln: more: - label_quantity: my_quantity_1 + label_quantity: '#/data/my_quantity_1' quantities: my_quantity_1: type: str diff --git a/examples/data/docs/tabular-parser_5_row_single-new-entry_to-path.archive.yaml b/examples/data/docs/tabular-parser_5_row_single-new-entry_to-path.archive.yaml index bd5f910dd3d5be679c8e22ba8a83eb2095eff8ad..b005f6854fd4d0e12e545906111b778f986965ba 100644 --- a/examples/data/docs/tabular-parser_5_row_single-new-entry_to-path.archive.yaml +++ b/examples/data/docs/tabular-parser_5_row_single-new-entry_to-path.archive.yaml @@ -41,7 +41,7 @@ definitions: m_annotations: eln: more: - label_quantity: my_quantity_1 + label_quantity: '#/data/my_quantity_1' sub_sections: my_repeated_sub_section: repeats: true diff --git a/examples/data/docs/tabular-parser_6_row_multiple-new-entries_to-root.archive.yaml b/examples/data/docs/tabular-parser_6_row_multiple-new-entries_to-root.archive.yaml index d90e5a3910383e4579047a59e3443a36bbec429e..5d71172d680084b6815c685bc79742a7d4c67992 100644 --- a/examples/data/docs/tabular-parser_6_row_multiple-new-entries_to-root.archive.yaml +++ b/examples/data/docs/tabular-parser_6_row_multiple-new-entries_to-root.archive.yaml @@ -8,7 +8,7 @@ definitions: m_annotations: eln: more: - label_quantity: my_quantity_1 + label_quantity: '#/data/my_quantity_1' quantities: data_file: type: str diff --git a/examples/data/docs/tabular-parser_7_row_multiple-new-entries_to-path.archive.yaml b/examples/data/docs/tabular-parser_7_row_multiple-new-entries_to-path.archive.yaml index 4fa7ac96f330584dc9754ef31c19ce5123e5416c..430da805977ab0fdbde1af71871950c88fd6ec0d 100644 --- a/examples/data/docs/tabular-parser_7_row_multiple-new-entries_to-path.archive.yaml +++ b/examples/data/docs/tabular-parser_7_row_multiple-new-entries_to-path.archive.yaml @@ -42,7 +42,7 @@ definitions: m_annotations: eln: more: - label_quantity: my_quantity_1 + label_quantity: '#/data/my_quantity_1' quantities: my_quantity_1: type: str diff --git a/examples/data/docs/tabular-parser_8_row_current-entry_to-path_subsubsection.archive.yaml b/examples/data/docs/tabular-parser_8_row_current-entry_to-path_subsubsection.archive.yaml index 23b7b67891f593a833bdcbb311e6c3e5fee0971f..8ed86ab3e4cc2d26e455281f96116fb3231b9286 100644 --- a/examples/data/docs/tabular-parser_8_row_current-entry_to-path_subsubsection.archive.yaml +++ b/examples/data/docs/tabular-parser_8_row_current-entry_to-path_subsubsection.archive.yaml @@ -32,7 +32,7 @@ definitions: m_annotations: eln: more: - label_quantity: my_quantity_1 + label_quantity: '#/data/my_quantity_1' quantities: my_quantity_1: type: str diff --git a/examples/data/tabular/README.md b/examples/data/tabular/README.md index 283c55d49149753b37d57563c3e22dc2f0bd91ca..6fa314b49611dc36a9be22d6eb60da8e0b313455 100644 --- a/examples/data/tabular/README.md +++ b/examples/data/tabular/README.md @@ -1,6 +1,6 @@ -This upload demonstrates the used of tabular data. In this example we use an *xlsx* file in combination with a custom schema. The schema describes what the columns in the excel file mean and NOMAD can parse everything accordingly to produce a **FAIR** dataset. +This upload demonstrates the use of tabular data. In this example we use an *xlsx* file in combination with a custom schema. The schema describes what columns in the excel file mean and how NOMAD is expected to parse and map the content accordingly in order to produce a **FAIR** dataset. -The schema is meant as a starting point. You can download the schema file and +This schema is meant as a starting point. You can download the schema file and extend the schema for your own tables. -Consult our [documentation on the NOMAD Archive and Metainfo](https://nomad-lab.eu/prod/v1/docs/archive.html) to learn more about schemas. +Consult our [documentation on the NOMAD Archive and Metainfo](https://nomad-lab.eu/prod/v1/staging/docs/) to learn more about schemas. diff --git a/examples/data/tabular/periodic-table.archive.xlsx b/examples/data/tabular/data.xlsx similarity index 100% rename from examples/data/tabular/periodic-table.archive.xlsx rename to examples/data/tabular/data.xlsx diff --git a/examples/data/tabular/periodic-table.archive.yaml b/examples/data/tabular/periodic-table.archive.yaml index 736b2f78a3ba66230ed2c2e79ecd2de333d23d6f..926e605fc97e4c77497a442ef6b50d8c718c7f33 100644 --- a/examples/data/tabular/periodic-table.archive.yaml +++ b/examples/data/tabular/periodic-table.archive.yaml @@ -4,20 +4,37 @@ definitions: name: Periodic Table sections: Element: + more: + label_quantity: '#/data/name' base_sections: - # We use ElnBaseSection here. This provides a few quantities (name, description, tags) + # We use ElnBaseSection here. This provides a few quantities (name, ags)description, t # that are added to the search index. If we map table columns to these quantities, # we can make those cells available for search. - nomad.datamodel.metainfo.eln.ElnBaseSection # Schemas that are used to directly parse table files (.csv, .xlsx), need to - # have the first definition to extend nomad.parsing.tabular.TableRow. - - nomad.parsing.tabular.TableRow + # have the first definition to extend nomad.parsing.tabular.TableData. + - nomad.parsing.tabular.TableData m_annotations: # We might not want to show all ElnBaseSection quantities. eln: hide: - lab_id quantities: + # data_file contains the information on how to parse the excel/csv file. Here we want to create + # as many entries as there are rows in the excel file and map the quantities annotated with 'tabular' from + # the tabular data into the nomad schema of each entry. + data_file: + type: str + default: data.xlsx + m_annotations: + tabular_parser: + parsing_options: + comment: '#' + mapping_options: + - mapping_mode: row + file_mode: multiple_new_entries + sections: + - '#root' # Tags will be picked up by ElnBaseSection and put into search. We do not really # use this to edit the tags, but we define a default that is then add to # all row data. diff --git a/gui/package.json b/gui/package.json index 5665c3dcc01630c4b3e016b2a62c312b6b71f41b..6504496fa555ef380e75f091cb86d1b0d4b4d521 100644 --- a/gui/package.json +++ b/gui/package.json @@ -17,6 +17,7 @@ "@material-ui/pickers": "^3.3.10", "@navjobs/upload": "^3.2.0", "@react-keycloak/web": "^3.4.0", + "@testing-library/react-hooks": "^8.0.1", "@tinymce/tinymce-react": "^4.1.0", "autosuggest-highlight": "^3.1.1", "base-64": "^1.0.0", diff --git a/gui/src/components/Actions.js b/gui/src/components/Actions.js index 59016454911a81e3fb4122b5f82811bdd68a73a8..52e56248fdcadb4b9bec51df3f2fb751895bd036 100644 --- a/gui/src/components/Actions.js +++ b/gui/src/components/Actions.js @@ -160,7 +160,7 @@ Action.propTypes = { onMouseUp: PropTypes.func, tooltip: PropTypes.string, TooltipProps: PropTypes.object, - ButtonComponent: PropTypes.object, + ButtonComponent: PropTypes.elementType, ButtonProps: PropTypes.object, className: PropTypes.string, classes: PropTypes.object, diff --git a/gui/src/components/App.js b/gui/src/components/App.js index 834c0b8306d6970e4f9c3ce7bad761fbbd31fc67..b6320753bab2041dc9e89367c857f02ad357db14 100644 --- a/gui/src/components/App.js +++ b/gui/src/components/App.js @@ -33,6 +33,7 @@ import Navigation from './nav/Navigation' import GUIMenu from './GUIMenu' import { APIProvider, GlobalLoginRequired, onKeycloakEvent } from './api' import DataStore from './DataStore' +import { UnitProvider } from './units/UnitContext' import { GlobalMetainfo } from './archive/metainfo' const keycloak = new Keycloak({ @@ -57,21 +58,26 @@ export default function App() { <MuiPickersUtilsProvider utils={DateFnsUtils}> <ErrorSnacks> <ErrorBoundary> - <DataStore> - <GlobalMetainfo> - <Router history={history}> - <QueryParamProvider ReactRouterRoute={Route}> - <MuiThemeProvider theme={nomadTheme}> - <CssBaseline /> - <GlobalLoginRequired> - <Navigation /> - <GUIMenu/> - </GlobalLoginRequired> - </MuiThemeProvider> - </QueryParamProvider> - </Router> - </GlobalMetainfo> - </DataStore> + <UnitProvider + initialUnitSystems={ui?.unit_systems?.options} + initialSelected={ui?.unit_systems?.selected} + > + <DataStore> + <GlobalMetainfo> + <Router history={history}> + <QueryParamProvider ReactRouterRoute={Route}> + <MuiThemeProvider theme={nomadTheme}> + <CssBaseline /> + <GlobalLoginRequired> + <Navigation /> + <GUIMenu/> + </GlobalLoginRequired> + </MuiThemeProvider> + </QueryParamProvider> + </Router> + </GlobalMetainfo> + </DataStore> + </UnitProvider> </ErrorBoundary> </ErrorSnacks> </MuiPickersUtilsProvider> diff --git a/gui/src/components/Help.js b/gui/src/components/Help.js index d86891016a0516107bf23513ed49638eca6f88d2..b888dea9e81ea6782ec0603fff7c5489255859fa 100644 --- a/gui/src/components/Help.js +++ b/gui/src/components/Help.js @@ -15,67 +15,57 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react' -import { Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, Tooltip } from '@material-ui/core' +import React, { useCallback, useState } from 'react' +import { Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions } from '@material-ui/core' import Markdown from './Markdown' import PropTypes from 'prop-types' import HelpIcon from '@material-ui/icons/Help' -export const HelpContext = React.createContext() +export const HelpButton = ({title, content, maxWidth, IconProps, children, ...IconButtonProps}) => { + const [open, setOpen] = useState(false) + const handleToggleOpen = useCallback(() => { + setOpen(old => !old) + IconButtonProps?.onClick?.() + }, [IconButtonProps]) -class HelpDialog extends React.Component { - static propTypes = { - title: PropTypes.string, - content: PropTypes.string.isRequired, - children: PropTypes.node, - maxWidth: PropTypes.string - } - - state = { - isOpen: false - } - - constructor(props) { - super(props) - this.handleOpen = this.handleOpen.bind(this) - this.handleClose = this.handleClose.bind(this) - } - - handleClose() { - this.setState({isOpen: false}) - } + return <> + <IconButton {...IconButtonProps} onClick={handleToggleOpen}> + {children || <HelpIcon {...IconProps}/>} + </IconButton> + <HelpDialog title={title} content={content} open={open} onClose={handleToggleOpen} maxWidth={maxWidth} /> + </> +} - handleOpen() { - this.setState({isOpen: true}) - } +HelpButton.propTypes = { + title: PropTypes.string, + content: PropTypes.string, + maxWidth: PropTypes.string, + IconProps: PropTypes.object, + children: PropTypes.node +} - render() { - const {title, content, children, maxWidth, ...rest} = this.props - return ( - <React.Fragment> - <Tooltip title={title}> - <IconButton {...rest} onClick={this.handleOpen}> - {children || <HelpIcon/>} - </IconButton> - </Tooltip> - <Dialog - maxWidth={maxWidth} - onClose={this.handleClose} - open={this.state.isOpen} - > - <DialogTitle>{title || 'Help'}</DialogTitle> - <DialogContent> - <Markdown>{content}</Markdown> - </DialogContent> - <DialogActions> - <Button onClick={() => this.handleClose()} color="primary"> - Close - </Button> - </DialogActions> - </Dialog> - </React.Fragment> - ) - } +const HelpDialog = ({title, content, maxWidth, open, onClose}) => { + return <Dialog + maxWidth={maxWidth} + onClose={onClose} + open={open} + > + <DialogTitle>{title || 'Help'}</DialogTitle> + <DialogContent> + <Markdown>{content}</Markdown> + </DialogContent> + <DialogActions> + <Button onClick={onClose} color="primary"> + Close + </Button> + </DialogActions> + </Dialog> } -export default HelpDialog +HelpDialog.propTypes = { + title: PropTypes.string, + content: PropTypes.string, + maxWidth: PropTypes.string, + open: PropTypes.bool, + onClose: PropTypes.func +} diff --git a/gui/src/components/Quantity.js b/gui/src/components/Quantity.js index c50365480e81548d1db0de7e32f61f40cdda8bf8..a01ca182d2d00aa7571191f2aef90a6f26615aa5 100644 --- a/gui/src/components/Quantity.js +++ b/gui/src/components/Quantity.js @@ -41,7 +41,9 @@ import Placeholder from './visualization/Placeholder' import Ellipsis from './visualization/Ellipsis' import NoData from './visualization/NoData' import { formatNumber, formatTimestamp, authorList, serializeMetainfo } from '../utils' -import { Quantity as Q, Unit, useUnits } from '../units' +import { Quantity as Q } from './units/Quantity' +import { Unit } from './units/Unit' +import { useUnitContext } from './units/UnitContext' import { defaultFilterData } from './search/FilterRegistry' import { MaterialLink, RouteLink } from './nav/Routes' @@ -157,7 +159,7 @@ const Quantity = React.memo((props) => { ((presets.renderValue && !isNil(value)) && presets.renderValue(value)) || ((quantity?.name && !isNil(quantity.type)) && getRenderFromType(quantity, data)) - const units = useUnits() + const {units} = useUnitContext() let content = null let clipboardContent = null diff --git a/gui/src/components/UnitSelector.js b/gui/src/components/UnitSelector.js deleted file mode 100644 index b93a69c446e2821389469fbdf89c97cfb85c8cef..0000000000000000000000000000000000000000 --- a/gui/src/components/UnitSelector.js +++ /dev/null @@ -1,189 +0,0 @@ -/* - * 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 React, { useCallback } from 'react' -import { useRecoilState } from 'recoil' -import { isNil } from 'lodash' -import { makeStyles } from '@material-ui/core/styles' -import { - Button, - Menu, - MenuItem, - FormControl, - InputLabel, - Select, - FormLabel, - FormControlLabel, - RadioGroup, - Radio, - Tooltip -} from '@material-ui/core' -import SettingsIcon from '@material-ui/icons/Settings' -import PropTypes from 'prop-types' -import clsx from 'clsx' -import { unitMap, unitSystems, unitsState, dimensionMap } from '../units' - -/** - * Unit selection menu with dropdowns for each dimension and presets for - * different unit systems. - */ -const useStyles = makeStyles((theme) => { - return { - root: { - }, - menuItem: { - width: '15rem' - }, - systems: { - margin: theme.spacing(2), - marginTop: theme.spacing(1), - marginBottom: theme.spacing(1) - } - } -}) -const UnitSelector = React.memo(({ - className, - classes, - onUnitChange, - onSystemChange -}) => { - // States - const [anchorEl, setAnchorEl] = React.useState(null) - const open = Boolean(anchorEl) - const [units, setUnits] = useRecoilState(unitsState) - const styles = useStyles({classes: classes}) - - // Callbacks - const openMenu = useCallback((event) => { - setAnchorEl(event.currentTarget) - }, []) - const closeMenu = useCallback(() => { - setAnchorEl(null) - }, []) - - // Used to handle unit system change. - const handleSystemChange = useCallback((event) => { - setUnits(unitSystems[event.target.value]) - onSystemChange && onSystemChange(event) - }, [onSystemChange, setUnits]) - - // Used to handle unit change for a specific dimensionality. The changes are - // stored for each system separately. - const handleUnitChange = useCallback(event => { - const dimension = event.target.name - const unit = event.target.value - setUnits(old => { - const newSystem = { - ...old, - units: { - ...old.units, - ...{[dimension]: {...old.units[dimension], name: unit}} - } - } - unitSystems[old.label] = newSystem - return unitSystems[old.label] - }) - onUnitChange && onUnitChange(event) - }, [onUnitChange, setUnits]) - - // Ordered list of controllable units. The 'dimensionless' unit cannot be - // changed. - const dimensions = Object.entries(dimensionMap) - .filter(([dimension, info]) => dimension !== 'dimensionless') - - return <> - <Button - aria-controls="customized-menu" - aria-haspopup="true" - variant="text" - color="primary" - onClick={openMenu} - className={clsx(styles.root, className)} - startIcon={<SettingsIcon/>} - > - Units - </Button> - <Menu - id="select-unit" - anchorEl={anchorEl} - getContentAnchorEl={null} - anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} - transformOrigin={{ vertical: 'top', horizontal: 'right' }} - keepMounted - open={open} - onClose={closeMenu} - > - <FormControl - component="fieldset" - classes={{root: styles.systems}} - > - <FormLabel component="legend">Unit system</FormLabel> - <RadioGroup name="unit-system" value={units.label} onChange={handleSystemChange}> - {Object.values(unitSystems).map(system => { - return <Tooltip key={system.label} title={system.description}> - <FormControlLabel value={system.label} control={<Radio />} label={system.label} /> - </Tooltip> - })} - </RadioGroup> - </FormControl> - {dimensions.map(([dimension, unitInfo]) => { - const unitDef = units.units[dimension] - if (isNil(unitDef)) { - return null - } - const selectedUnit = unitDef.name - const disabled = unitDef.fixed - return <MenuItem - key={dimension} - > - <FormControl disabled={disabled}> - <InputLabel id="demo-simple-select-label">{unitInfo.label}</InputLabel> - <Select - classes={{root: styles.menuItem}} - labelId="demo-simple-select-label" - id="demo-simple-select" - name={dimension} - value={selectedUnit} - onChange={handleUnitChange} - > - {unitInfo.units.map((unit) => { - const unitLabel = unitMap[unit].label - const unitAbbreviation = unitMap[unit].abbreviation - return <MenuItem key={unit} value={unit}>{`${unitLabel} (${unitAbbreviation})`}</MenuItem> - })} - </Select> - </FormControl> - </MenuItem> - })} - </Menu> - </> -}) - -UnitSelector.propTypes = { - /** - * Callback for unit selection. - */ - onUnitChange: PropTypes.func, - /** - * Callback for unit system selection. - */ - onSystemChange: PropTypes.func, - className: PropTypes.string, - classes: PropTypes.object -} - -export default UnitSelector diff --git a/gui/src/components/archive/ArchiveBrowser.js b/gui/src/components/archive/ArchiveBrowser.js index 950d72babd887020707445297abdbf879c34169e..50e8065de41e583eb3f8883fdeba527695d2ff14 100644 --- a/gui/src/components/archive/ArchiveBrowser.js +++ b/gui/src/components/archive/ArchiveBrowser.js @@ -35,7 +35,8 @@ import { ArchiveTitle, DefinitionLabel, metainfoAdaptorFactory } from './Metainf import { Matrix, Number } from './visualizations' import Markdown from '../Markdown' import { Overview } from './Overview' -import { Quantity as Q, useUnits } from '../../units' +import { Quantity as Q } from '../units/Quantity' +import { useUnitContext } from '../units/UnitContext' import ArrowRightIcon from '@material-ui/icons/ArrowRight' import ArrowDownIcon from '@material-ui/icons/ArrowDropDown' import DownloadIcon from '@material-ui/icons/CloudDownload' @@ -648,7 +649,7 @@ const convertComplexArray = (real, imag) => { } function QuantityItemPreview({value, def}) { - const units = useUnits() + const {units} = useUnitContext() if (isReference(def)) { return <Box component="span" fontStyle="italic"> <Typography component="span">reference ...</Typography> @@ -725,7 +726,7 @@ QuantityItemPreview.propTypes = ({ }) const QuantityValue = React.memo(function QuantityValue({value, def, ...more}) { - const units = useUnits() + const {units} = useUnitContext() const getRenderValue = useCallback(value => { let finalValue diff --git a/gui/src/components/archive/Overview.js b/gui/src/components/archive/Overview.js index fb1523305fbeb0c21e9ba23d1200aa9c689bd36e..f592a549d164ac866022147dbe04c094ce528435 100644 --- a/gui/src/components/archive/Overview.js +++ b/gui/src/components/archive/Overview.js @@ -12,7 +12,8 @@ import BrillouinZone from '../visualization/BrillouinZone' import BandStructure from '../visualization/BandStructure' import Spectra from '../visualization/Spectra' import DOS from '../visualization/DOS' -import { Quantity, useUnits } from '../../units' +import { Quantity } from '../units/Quantity' +import { useUnitContext } from '../units/UnitContext' import { electronicRange } from '../../config' import EnergyVolumeCurve from '../visualization/EnergyVolumeCurve' @@ -262,7 +263,7 @@ OverviewEquationOfState.propTypes = ({ export const Overview = React.memo((props) => { const {def} = props - const units = useUnits() + const {units} = useUnitContext() const path = window.location.href.split('/').pop().split(':')[0] if (def.name === 'BandStructure' && path === 'band_structure_electronic') { diff --git a/gui/src/components/archive/PlotlyFigure.js b/gui/src/components/archive/PlotlyFigure.js index 2addff548c452b21758d9db165d9b31113908dd1..793d59e4cb8812df7c0cce657a55f9b42999b182 100644 --- a/gui/src/components/archive/PlotlyFigure.js +++ b/gui/src/components/archive/PlotlyFigure.js @@ -1,6 +1,7 @@ import React, {useMemo} from 'react' import {Box} from '@material-ui/core' -import {Quantity as Q, useUnits} from '../../units' +import {Quantity as Q} from '../units/Quantity' +import {useUnitContext} from '../units/UnitContext' import {titleCase, resolveInternalRef} from '../../utils' import {cloneDeep, merge} from 'lodash' import Plot from '../plotting/Plot' @@ -29,7 +30,7 @@ const traverse = (value, callback, parent = null, key = null) => { } const PlotlyFigure = React.memo(function PlotlyFigure({plot, section, sectionDef, title, metaInfoLink}) { - const units = useUnits() + const {units} = useUnitContext() const plotlyGraphObj = useMemo(() => { if (!sectionDef?._properties) { diff --git a/gui/src/components/archive/XYPlot.js b/gui/src/components/archive/XYPlot.js index b4776e8ca679aec7ca9586ee01bd82efb1acc848..6046127018e0da1b3252cfe7f39a07ff616ae37d 100644 --- a/gui/src/components/archive/XYPlot.js +++ b/gui/src/components/archive/XYPlot.js @@ -1,6 +1,7 @@ import React, {useMemo} from 'react' import {Box, useTheme} from '@material-ui/core' -import {Quantity as Q, useUnits} from '../../units' +import {Quantity as Q} from '../units/Quantity' +import { useUnitContext } from '../units/UnitContext' import {titleCase, resolveInternalRef} from '../../utils' import {getLineStyles} from '../plotting/common' import { merge } from 'lodash' @@ -17,7 +18,7 @@ class XYPlotError extends Error { const XYPlot = React.memo(function XYPlot({plot, section, sectionDef, title}) { const theme = useTheme() - const units = useUnits() + const {units} = useUnitContext() const xAxis = plot.x || plot['x_axis'] || plot['xAxis'] const yAxis = plot.y || plot['y_axis'] || plot['yAxis'] diff --git a/gui/src/components/buttons/DownloadSystemButton.js b/gui/src/components/buttons/DownloadSystemButton.js index dc10f252573717bed2ec8979fcccfa13823ce4fa..a7f87253a7bf1abe3cfbe26e9aa33b414ab68188 100644 --- a/gui/src/components/buttons/DownloadSystemButton.js +++ b/gui/src/components/buttons/DownloadSystemButton.js @@ -20,19 +20,24 @@ import PropTypes from 'prop-types' import { Menu, MenuItem, - InputLabel, FormControl, + FormLabel, + FormControlLabel, IconButton, - Select, + TextField, + Typography, DialogActions, - Box + Box, + Tooltip, + Radio, + RadioGroup } from '@material-ui/core' import LoadingButton from './LoadingButton' import { download } from '../../utils' import { useErrors } from '../errors' import { useApi } from '../api' -// TODO: The available formats could be passed down to the GUI via pydantic +// TODO: The available options could be passed down to the GUI via pydantic // model serialization. const formats = { cif: { @@ -45,6 +50,54 @@ const formats = { label: 'PDB' } } +export const wrapModes = { + original: { + key: 'original', + label: 'Original', + description: 'Original positions' + }, + wrap: { + key: 'wrap', + label: 'Wrap', + description: 'Positions are wrapped to be inside the cell respecting periodic boundary conditions' + }, + unwrap: { + key: 'unwrap', + label: 'Unwrap', + description: `Positions are reconstructed so that the structure is not split + by periodic cell boundaries. Note that this produces meaningful results only + if the system dimensions are smaller than the unit cell.` + } +} + +export const WrapModeRadio = ({value, onChange, disabled, className}) => { + return <FormControl key='wrap' component="fieldset" className={className}> + <FormLabel component="legend">Wrap mode</FormLabel> + <RadioGroup + value={value} + onChange={onChange} + > + {Object.entries(wrapModes).map(([key, data]) => + <FormControlLabel + key={key} + value={key} + control={<Radio color="primary" disabled={disabled}/>} + label={<Tooltip + title={wrapModes[key].description}> + <span>{data.label}</span> + </Tooltip>} + /> + )} + </RadioGroup> + </FormControl> +} + +WrapModeRadio.propTypes = { + value: PropTypes.string, + onChange: PropTypes.func, + disabled: PropTypes.bool, + className: PropTypes.string +} /* * Menu for downloading a specific system. @@ -53,6 +106,7 @@ export const DownloadSystemMenu = React.memo(React.forwardRef(({entryId, path, a const {api} = useApi() const {raiseError} = useErrors() const [format, setFormat] = useState('cif') + const [wrapMode, setWrapMode] = useState('original') const [loading, setLoading] = useState(false) const open = Boolean(anchorEl) @@ -60,10 +114,14 @@ export const DownloadSystemMenu = React.memo(React.forwardRef(({entryId, path, a setFormat(event.target.value) }, []) + const handleChangeWrapMode = useCallback((event) => { + setWrapMode(event.target.value) + }, []) + const handleClickDownload = useCallback(() => { setLoading(true) api.get( - `systems/${entryId}?path=${path}&format=${format}`, + `systems/${entryId}?path=${path}&format=${format}&wrap_mode=${wrapMode}`, undefined, {responseType: 'blob', fullResponse: true} ) @@ -74,7 +132,7 @@ export const DownloadSystemMenu = React.memo(React.forwardRef(({entryId, path, a }) .finally(() => setLoading(false)) .catch(raiseError) - }, [entryId, path, format, api, raiseError]) + }, [entryId, path, format, wrapMode, api, raiseError]) return <Menu anchorEl={anchorEl} @@ -85,19 +143,18 @@ export const DownloadSystemMenu = React.memo(React.forwardRef(({entryId, path, a open={open} onClose={onClose} > - <Box minWidth="10rem" paddingLeft={2} paddingRight={2} paddingTop={1}> - <FormControl fullWidth> - <InputLabel>Format</InputLabel> - <Select - name="Format" - value={format} - onChange={handleChangeFormat} - > - {Object.entries(formats).map(([key, value]) => { - return <MenuItem key={key} value={key}>{value.label}</MenuItem> - })} - </Select> - </FormControl> + <Box minWidth="13rem" paddingLeft={2} paddingRight={2} paddingTop={1}> + <Typography variant='h6' fontSize='0.9rem'> + Download system + </Typography> + <Box mt={1} /> + <TextField select value={format} onChange={handleChangeFormat} size='small' variant="filled" label="Format" fullWidth> + {Object.entries(formats).map(([key, value]) => { + return <MenuItem key={key} value={key}>{value.label}</MenuItem> + })} + </TextField> + <Box mt={2} /> + <WrapModeRadio value={wrapMode} onChange={handleChangeWrapMode} /> <Box marginRight={-1} marginBottom={-1}> <DialogActions> <LoadingButton onClick={handleClickDownload} color="primary" loading={loading}> diff --git a/gui/src/components/conftest.spec.js b/gui/src/components/conftest.spec.js index e1cc8dc8756a7535a0b3928940d2fbf72a22d146..9596e0cecf8508669141332edbcb10ced6374178 100644 --- a/gui/src/components/conftest.spec.js +++ b/gui/src/components/conftest.spec.js @@ -39,10 +39,11 @@ import { seconds, server } from '../setupTests' import { Router, MemoryRouter } from 'react-router-dom' import { createBrowserHistory } from 'history' import { APIProvider } from './api' +import { UnitProvider } from './units/UnitContext' import { ErrorSnacks, ErrorBoundary } from './errors' import DataStore from './DataStore' import { defaultFilterData } from './search/FilterRegistry' -import { keycloakBase, searchQuantities } from '../config' +import { keycloakBase, searchQuantities, ui } from '../config' import { useKeycloak } from '@react-keycloak/web' import { GlobalMetainfo } from './archive/metainfo' @@ -104,15 +105,20 @@ export const WrapperDefault = ({children}) => { <MuiPickersUtilsProvider utils={DateFnsUtils}> <ErrorSnacks> <ErrorBoundary> - <DataStore> - <GlobalMetainfo> - <Router history={createBrowserHistory({basename: process.env.PUBLIC_URL})}> - <MemoryRouter> - {children} - </MemoryRouter> - </Router> - </GlobalMetainfo> - </DataStore> + <UnitProvider + initialUnitSystems={ui?.unit_systems?.options} + initialSelected={ui?.unit_systems?.selected} + > + <DataStore> + <GlobalMetainfo> + <Router history={createBrowserHistory({basename: process.env.PUBLIC_URL})}> + <MemoryRouter> + {children} + </MemoryRouter> + </Router> + </GlobalMetainfo> + </DataStore> + </UnitProvider> </ErrorBoundary> </ErrorSnacks> </MuiPickersUtilsProvider> @@ -140,7 +146,12 @@ export const WrapperNoAPI = ({children}) => { <MemoryRouter> <ErrorSnacks> <ErrorBoundary> - {children} + <UnitProvider + initialUnitSystems={ui?.unit_systems?.options} + initialSelected={ui?.unit_systems?.selected} + > + {children} + </UnitProvider> </ErrorBoundary> </ErrorSnacks> </MemoryRouter> diff --git a/gui/src/components/editQuantity/NumberEditQuantity.js b/gui/src/components/editQuantity/NumberEditQuantity.js index b9d688c27e282cfae3779217949cc3991cb0d7d6..262affa808076eef318924288104d53d1fd592ca 100644 --- a/gui/src/components/editQuantity/NumberEditQuantity.js +++ b/gui/src/components/editQuantity/NumberEditQuantity.js @@ -19,7 +19,9 @@ 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 {getUnits, Unit, Quantity, useUnits, parseQuantity} from '../../units' +import {Quantity, parseQuantity} from '../units/Quantity' +import {Unit} from '../units/Unit' +import {useUnitContext, getUnits} from '../units/UnitContext' import {debounce, isNil} from 'lodash' import {TextFieldWithHelp, getFieldProps} from './StringEditQuantity' import {useErrors} from '../errors' @@ -203,7 +205,7 @@ NumberField.propTypes = { export const NumberEditQuantity = React.memo((props) => { const {quantityDef, value, onChange, ...otherProps} = props - const systemUnits = useUnits() + const {units} = useUnitContext() const {raiseError} = useErrors() const defaultUnit = useMemo(() => quantityDef.unit && new Unit(quantityDef.unit), [quantityDef]) const dimension = defaultUnit && defaultUnit.dimension(false) @@ -229,7 +231,7 @@ export const NumberEditQuantity = React.memo((props) => { const [unit, setUnit] = useState( defaultDisplayUnitObj || - (systemUnits[dimension]?.name && new Unit(systemUnits[dimension]?.name)) || + (units[dimension]?.definition && new Unit(units[dimension]?.definition)) || defaultUnit ) @@ -256,7 +258,6 @@ export const NumberEditQuantity = React.memo((props) => { // Handle a change in the unit dialog const handleUnitChange = useCallback((newUnit) => { if (!checked && quantityDef.unit && newUnit && !isNil(value)) { - // const displayedValue = new Quantity(value, quantityDef.unit).to(unit).value() const storedValue = new Quantity(Number(displayedValue), newUnit).to(quantityDef.unit).value() onChange(storedValue) } diff --git a/gui/src/components/editQuantity/SliderEditQuantity.js b/gui/src/components/editQuantity/SliderEditQuantity.js index e647fd9dba2a2f76bc1b3a569da637dff4ddf586..4c6b9c4bdb6a60c71c4c155d100333b74e558807 100644 --- a/gui/src/components/editQuantity/SliderEditQuantity.js +++ b/gui/src/components/editQuantity/SliderEditQuantity.js @@ -22,7 +22,9 @@ import { FormLabel, Slider } from '@material-ui/core' import PropTypes from 'prop-types' -import {Quantity, Unit, useUnits} from '../../units' +import {Quantity} from '../units/Quantity' +import {Unit} from '../units/Unit' +import {useUnitContext} from '../units/UnitContext' import {UnitSelect} from './NumberEditQuantity' import {getFieldProps} from './StringEditQuantity' @@ -30,10 +32,10 @@ export const SliderEditQuantity = React.memo((props) => { const {quantityDef, value, onChange, minValue, maxValue, ...sliderProps} = props const {label} = getFieldProps(quantityDef) - const systemUnits = useUnits() + const {units} = useUnitContext() const defaultUnit = useMemo(() => quantityDef.unit && new Unit(quantityDef.unit), [quantityDef]) const dimension = defaultUnit && defaultUnit.dimension() - const [unit, setUnit] = useState(systemUnits[dimension] || quantityDef.unit) + const [unit, setUnit] = useState(units[dimension]?.definition || quantityDef.unit) const minValueConverted = useMemo(() => { return unit ? new Quantity(minValue, quantityDef.unit).to(unit).value() diff --git a/gui/src/components/entry/conftest.spec.js b/gui/src/components/entry/conftest.spec.js index 485a27c22d0e8b69daa99e1142f0266af94d91f4..ff1d62ca5b843a52833c0b832e4842059c95909e 100644 --- a/gui/src/components/entry/conftest.spec.js +++ b/gui/src/components/entry/conftest.spec.js @@ -22,7 +22,7 @@ import userEvent from '@testing-library/user-event' import { expectQuantity, screen } from '../conftest.spec' import { expectPlotButtons } from '../visualization/conftest.spec' import { traverseDeep, serializeMetainfo } from '../../utils' -import { unitSystems } from '../../units' +import { ui } from '../../config' /*****************************************************************************/ // Expects @@ -115,7 +115,7 @@ export async function expectMethodologyItem( expect(root.getByText(title)).toBeInTheDocument() for (const [key, value] of traverseDeep(data, true)) { const quantity = `${path}.${key.join('.')}` - expectQuantity(quantity, serializeMetainfo(quantity, value, unitSystems.Custom.units)) + expectQuantity(quantity, serializeMetainfo(quantity, value, ui.unit_systems.options.Custom.units)) } } } diff --git a/gui/src/components/entry/properties/MechanicalPropertiesCard.js b/gui/src/components/entry/properties/MechanicalPropertiesCard.js index 8e1f1e439935389ba97d0d172ca6637ab0dfcec0..46ca35928c1b1be7f60aad584aa6185fb0ff39ee 100644 --- a/gui/src/components/entry/properties/MechanicalPropertiesCard.js +++ b/gui/src/components/entry/properties/MechanicalPropertiesCard.js @@ -18,7 +18,7 @@ import React from 'react' import PropTypes from 'prop-types' import { PropertyCard } from './PropertyCard' -import { useUnits } from '../../../units' +import { useUnitContext } from '../../units/UnitContext' import { getLocation, resolveInternalRef } from '../../../utils' import { refPath } from '../../archive/metainfo' import MechanicalProperties from '../../visualization/MechanicalProperties' @@ -27,7 +27,7 @@ import MechanicalProperties from '../../visualization/MechanicalProperties' * Card displaying mechanical properties. */ const MechanicalPropertiesCard = React.memo(({index, properties, archive}) => { - const units = useUnits() + const {units} = useUnitContext() const urlPrefix = `${getLocation()}/data` // Find out which properties are present diff --git a/gui/src/components/entry/properties/VibrationalPropertiesCard.js b/gui/src/components/entry/properties/VibrationalPropertiesCard.js index 4ce812cdfcee4cf1bf41d104d1c4d66dc7522d41..6fc49aac6aff192bc81eae0666b61f5209df088d 100644 --- a/gui/src/components/entry/properties/VibrationalPropertiesCard.js +++ b/gui/src/components/entry/properties/VibrationalPropertiesCard.js @@ -18,7 +18,7 @@ import React from 'react' import PropTypes from 'prop-types' import { PropertyCard } from './PropertyCard' -import { useUnits } from '../../../units' +import { useUnitContext } from '../../units/UnitContext' import { getLocation, resolveInternalRef } from '../../../utils' import { refPath } from '../../archive/metainfo' import VibrationalProperties from '../../visualization/VibrationalProperties' @@ -27,7 +27,7 @@ import VibrationalProperties from '../../visualization/VibrationalProperties' * Card displaying vibrational properties. */ const VibrationalPropertiesCard = React.memo(({index, properties, archive}) => { - const units = useUnits() + const {units} = useUnitContext() const urlPrefix = `${getLocation()}/data` // Find out which properties are present diff --git a/gui/src/components/nav/AppBar.js b/gui/src/components/nav/AppBar.js index 51a5085a16db5a91ff31f3f904019533144cc97f..d990a75fd32b5f4cc705dca8ae68a0430e761f3d 100644 --- a/gui/src/components/nav/AppBar.js +++ b/gui/src/components/nav/AppBar.js @@ -26,7 +26,7 @@ import { makeStyles } from '@material-ui/core' import LoginLogout from '../LoginLogout' -import UnitSelector from '../UnitSelector' +import UnitMenu from '../units/UnitMenu' import MainMenu from './MainMenu' import { useLoading } from '../api' import { guiBase, oasis } from '../../config' @@ -64,7 +64,6 @@ const useStyles = makeStyles(theme => ({ toolbar: { display: 'flex', flexDirection: 'row' - // paddingRight: theme.spacing(3) }, logoImg: { height: 44, @@ -87,8 +86,6 @@ const useStyles = makeStyles(theme => ({ navigation: { flexGrow: 1, marginRight: theme.spacing(1), - // marginBottom: theme.spacing(1), - // marginTop: theme.spacing(0.25), display: 'flex', flexDirection: 'column', alignItems: 'flex-start', @@ -124,7 +121,7 @@ export default function AppBar() { </div> <div className={styles.actions}> <LoginLogout color="primary" classes={{button: styles.menuItem}} /> - <UnitSelector className={styles.menuItem}></UnitSelector> + <UnitMenu className={styles.menuItem} /> </div> </Toolbar> <LoadingIndicator className={styles.progress}/> diff --git a/gui/src/components/nav/Breadcrumbs.js b/gui/src/components/nav/Breadcrumbs.js index b6dea180c31fc01e83ef3792a8e8f1b32cf38dab..038438e23bb09a3de59fd319dc1229ca96904bd6 100644 --- a/gui/src/components/nav/Breadcrumbs.js +++ b/gui/src/components/nav/Breadcrumbs.js @@ -18,9 +18,8 @@ import React, { useCallback, useMemo } from 'react' import { matchPath, useLocation, Link as RouterLink } from 'react-router-dom' -import { Typography, Breadcrumbs as MUIBreadcrumbs, Link, Box, makeStyles } from '@material-ui/core' -import HelpDialog from '../Help' -import HelpIcon from '@material-ui/icons/Help' +import { Typography, Breadcrumbs as MUIBreadcrumbs, Link, Box, makeStyles, Tooltip } from '@material-ui/core' +import { HelpButton } from '../Help' import { allRoutes } from './Routes' import {useDataStore} from "../DataStore" @@ -32,9 +31,6 @@ const useStyles = makeStyles(theme => ({ help: { marginLeft: theme.spacing(0.5) }, - helpIcon: { - fontSize: 18 - }, ellipsis: { direction: 'rtl', textAlign: 'left', @@ -89,9 +85,11 @@ const Breadcrumbs = React.memo(function Breadcrumbs() { return <Box key={i} display="flex" flexDirection="row" alignItems="center"> {title} {route.help && ( - <HelpDialog className={styles.help} size="small" {...route.help}> - <HelpIcon className={styles.helpIcon} /> - </HelpDialog> + <Tooltip title={route?.help?.title || ""}> + <span> + <HelpButton className={styles.help} size="small" IconProps={{fontSize: 'small'}} {...route.help} /> + </span> + </Tooltip> )} </Box> } else { diff --git a/gui/src/components/plotting/PlotAxis.js b/gui/src/components/plotting/PlotAxis.js index c0ecebd43fc18579f3389295c7d72e17b400a36a..fedb11293b1bef788ca5fe27fe166af3447a90c8 100644 --- a/gui/src/components/plotting/PlotAxis.js +++ b/gui/src/components/plotting/PlotAxis.js @@ -22,7 +22,9 @@ import { makeStyles } from '@material-ui/core/styles' import { isArray, isNil } from 'lodash' import { useResizeDetector } from 'react-resize-detector' import { getScaler, getTicks } from './common' -import { useUnits, Quantity, Unit } from '../../units' +import { Quantity } from '../units/Quantity' +import { Unit } from '../units/Unit' +import { useUnitContext } from '../units/UnitContext' import { formatNumber, DType } from '../../utils' import PlotLabel from './PlotLabel' import PlotTick from './PlotTick' @@ -93,7 +95,7 @@ const PlotAxis = React.memo(({ classes, 'data-testid': testID}) => { const styles = usePlotAxisStyles(classes) - const units = useUnits() + const {units} = useUnitContext() const unitObj = useMemo(() => new Unit(unit), [unit]) const {height, width, ref} = useResizeDetector() const orientation = { diff --git a/gui/src/components/plotting/PlotHistogram.js b/gui/src/components/plotting/PlotHistogram.js index 97822c37c715d6e56073c47943ad360548897751..a52074a9a5294ba892477feb663f6805ddfe4e70 100644 --- a/gui/src/components/plotting/PlotHistogram.js +++ b/gui/src/components/plotting/PlotHistogram.js @@ -22,7 +22,7 @@ import { useRecoilValue } from 'recoil' import { Slider } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { pluralize, formatInteger } from '../../utils' -import { Unit } from '../../units' +import { Unit } from '../units/Unit' import InputUnavailable from '../search/input/InputUnavailable' import Placeholder from '../visualization/Placeholder' import PlotAxis from './PlotAxis' diff --git a/gui/src/components/plotting/PlotScatter.js b/gui/src/components/plotting/PlotScatter.js index fa14120e56086fc0c2f8fe7d79b8837db4bb848c..33b950ad4c4c15e8db1a9c50bca3d61d77f13114 100644 --- a/gui/src/components/plotting/PlotScatter.js +++ b/gui/src/components/plotting/PlotScatter.js @@ -19,7 +19,9 @@ import React, {useState, useEffect, useMemo, useCallback, forwardRef} from 'reac import PropTypes from 'prop-types' import { makeStyles, useTheme } from '@material-ui/core' import { getDeep, hasWebGLSupport, parseQuantityName } from '../../utils' -import { useUnits, Quantity, Unit } from '../../units' +import { Quantity } from '../units/Quantity' +import { Unit } from '../units/Unit' +import { useUnitContext } from '../units/UnitContext' import * as d3 from 'd3' import { isArray, isNil } from 'lodash' import FilterTitle from '../search/FilterTitle' @@ -108,7 +110,7 @@ const PlotScatter = React.memo(forwardRef(( const styles = useStyles() const theme = useTheme() const [finalData, setFinalData] = useState(!data ? data : undefined) - const units = useUnits() + const {units} = useUnitContext() const { filterData } = useSearchContext() const history = useHistory() diff --git a/gui/src/components/search/Filter.js b/gui/src/components/search/Filter.js index 81d3241f265bbec58ace36c3e5b55e68d5281057..d646189117b573ffb949b61b324289ceb5c4153b 100644 --- a/gui/src/components/search/Filter.js +++ b/gui/src/components/search/Filter.js @@ -25,7 +25,7 @@ import { DType, multiTypes } from '../../utils' -import { Unit } from '../../units' +import { Unit } from '../units/Unit' /** * Filter is a wrapper for metainfo (quantity or section) that can be searched. diff --git a/gui/src/components/search/FilterSummary.js b/gui/src/components/search/FilterSummary.js index ebd396bc1e92ca89dddbdcaa5db2bb3b95badb6a..16b21329cfe66fbfb261875c7e14315feef577fc 100644 --- a/gui/src/components/search/FilterSummary.js +++ b/gui/src/components/search/FilterSummary.js @@ -22,7 +22,7 @@ import clsx from 'clsx' import { isNil, isPlainObject } from 'lodash' import { FilterChip, FilterChipGroup, FilterAnd, FilterOr } from './FilterChip' import { useSearchContext } from './SearchContext' -import { useUnits } from '../../units' +import { useUnitContext } from '../units/UnitContext' /** * Smart component that displays a set of FilterGroups and FilterChips for the @@ -61,7 +61,7 @@ const FilterSummary = React.memo(({ const filters = useFilters(quantities) const updateFilter = useUpdateFilter() const theme = useTheme() - const units = useUnits() + const {units} = useUnitContext() const styles = useStyles({classes: classes, theme: theme}) // Creates a set of chips for a quantity diff --git a/gui/src/components/search/FilterTitle.js b/gui/src/components/search/FilterTitle.js index 65a3df0c0b4af0bc7027c960bb7e70460ea10121..5e53bcbec174c427e7c7ed5545f3965423715b03 100644 --- a/gui/src/components/search/FilterTitle.js +++ b/gui/src/components/search/FilterTitle.js @@ -22,7 +22,8 @@ import PropTypes from 'prop-types' import clsx from 'clsx' import { useSearchContext } from './SearchContext' import { inputSectionContext } from './input/InputSection' -import { useUnits, Unit } from '../../units' +import { Unit } from '../units/Unit' +import { useUnitContext } from '../units/UnitContext' /** * Title for a metainfo quantity or section that is used in a search context. @@ -68,7 +69,7 @@ const FilterTitle = React.memo(({ const styles = useStaticStyles({classes: classes}) const { filterData } = useSearchContext() const sectionContext = useContext(inputSectionContext) - const units = useUnits() + const {units} = useUnitContext() const section = sectionContext?.section // Create the final label diff --git a/gui/src/components/search/SearchBar.js b/gui/src/components/search/SearchBar.js index 336353c054af4222a976b0d26cfe1f5d2292d9ff..f08765dc5813e77b4e775d521f11e875f8b31cab 100644 --- a/gui/src/components/search/SearchBar.js +++ b/gui/src/components/search/SearchBar.js @@ -32,7 +32,6 @@ import { ListItemText } from '@material-ui/core' import IconButton from '@material-ui/core/IconButton' -import { useUnits } from '../../units' import { DType, getSchemaAbbreviation } from '../../utils' import { useSuggestions } from '../../hooks' import { useSearchContext } from './SearchContext' @@ -94,7 +93,6 @@ const SearchBar = React.memo(({ className }) => { const styles = useStyles() - const units = useUnits() const { filters, filterData, @@ -188,7 +186,7 @@ const SearchBar = React.memo(({ const presence = inputValue.match(new RegExp(`^\\s*(${reString})\\s*=\\s*\\*\\s*$`)) if (presence) { quantityFullname = `quantities` - queryValue = parseQuery(quantityFullname, presence[1], units) // are units still necessary? + queryValue = parseQuery(quantityFullname, presence[1]) valid = true } @@ -204,7 +202,7 @@ const SearchBar = React.memo(({ return } try { - queryValue = parseQuery(quantityFullname, equals[2], units) + queryValue = parseQuery(quantityFullname, equals[2]) } catch (error) { setError(`Invalid value for this metainfo. Please check your syntax.`) return @@ -240,7 +238,7 @@ const SearchBar = React.memo(({ } let quantityValue try { - quantityValue = parseQuery(quantityFullname, value, units, undefined, false) + quantityValue = parseQuery(quantityFullname, value, undefined, false) } catch (error) { console.log(error) setError(`Invalid value for this metainfo. Please check your syntax.`) @@ -274,8 +272,8 @@ const SearchBar = React.memo(({ } queryValue = {} try { - queryValue[opMapReverse[op1]] = parseQuery(quantityFullname, a, units, undefined, false) - queryValue[opMap[op2]] = parseQuery(quantityFullname, c, units, undefined, false) + queryValue[opMapReverse[op1]] = parseQuery(quantityFullname, a, undefined, false) + queryValue[opMap[op2]] = parseQuery(quantityFullname, c, undefined, false) } catch (error) { setError(`Invalid value for this metainfo. Please check your syntax.`) return @@ -303,7 +301,7 @@ const SearchBar = React.memo(({ } else { setError(`Invalid query`) } - }, [inputValue, checkMetainfo, units, updateFilter, filterData, parseQuery, filtersLocked]) + }, [inputValue, checkMetainfo, updateFilter, filterData, parseQuery, filtersLocked]) // Handle clear button const handleClose = useCallback(() => { diff --git a/gui/src/components/search/SearchContext.js b/gui/src/components/search/SearchContext.js index edda4e0c2b49e74f25260fe10938e017fb48087d..a35fa6e3d6a47f965f87b1e1b3b6b343a33aa63b 100644 --- a/gui/src/components/search/SearchContext.js +++ b/gui/src/components/search/SearchContext.js @@ -61,13 +61,15 @@ import { rsplit, parseOperator } from '../../utils' -import { Quantity, Unit } from '../../units' +import { Quantity } from '../units/Quantity' +import { Unit } from '../units/Unit' import { useErrors } from '../errors' import { combinePagination, addColumnDefaults } from '../datatable/Datatable' import UploadStatusIcon from '../uploads/UploadStatusIcon' import { getWidgetsObject } from './widgets/Widget' import { inputSectionContext } from './input/InputSection' import { withFilters } from './FilterRegistry' +import { useUnitContext } from '../units/UnitContext' const useWidthConstrainedStyles = makeStyles(theme => ({ root: { @@ -200,6 +202,7 @@ export const SearchContextRaw = React.memo(({ children }) => { const {api, user} = useApi() + const {units} = useUnitContext() const {raiseError} = useErrors() const oldQuery = useRef(undefined) const oldPagination = useRef(undefined) @@ -1518,7 +1521,7 @@ export const SearchContextRaw = React.memo(({ * */ const useParseQuery = () => { return useCallback( - (key, value, units, path, multiple) => parseQuery(key, value, filtersData, units, path, multiple), + (key, value, path, multiple) => parseQuery(key, value, filtersData, units, path, multiple), [] ) } @@ -1632,7 +1635,8 @@ export const SearchContextRaw = React.memo(({ updateAggsResponse, setPagination, setResults, - setApiData + setApiData, + units ]) return <searchContext.Provider value={values}> diff --git a/gui/src/components/search/SearchContext.spec.js b/gui/src/components/search/SearchContext.spec.js index 166b913c79b592bca1e4cfa20ae182217bdc9b78..2f2fc8e5faefcb866e0413a96874c20c564e7535 100644 --- a/gui/src/components/search/SearchContext.spec.js +++ b/gui/src/components/search/SearchContext.spec.js @@ -15,30 +15,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react' -import { renderSearchEntry } from './conftest.spec' +import { renderHook } from '@testing-library/react-hooks' +import { WrapperSearch } from './conftest.spec' import { useSearchContext } from './SearchContext' -import { unitSystems, Quantity } from '../../units' +import { Quantity } from '../units/Quantity' import { isEqualWith } from 'lodash' -/** - * Function that exposes the useSearchContext hook. - */ -function setup() { - const returnVal = {} - - function TestComponent() { - const {useParseQuery} = useSearchContext() - const parseQuery = useParseQuery() - Object.assign(returnVal, {parseQuery}) - return null - } - renderSearchEntry( - <TestComponent /> - ) - - return returnVal -} describe('parseQuery', function() { test.each([ ['unit not specified', 'results.material.topology.cell.a', '1', new Quantity(1, 'angstrom'), undefined], @@ -47,16 +29,18 @@ describe('parseQuery', function() { ['filter hat accepts multiple values is wrapped in set', 'results.material.material_id', 'abcd', new Set(['abcd']), undefined], ['filter that does not accept multiple values is not wrapped in set', 'visibility', 'public', 'public', undefined] ])('%s', async (name, quantity, input, output, error) => { - const parseQuery = setup().parseQuery + const { result: resultUseSearchContext } = renderHook(() => useSearchContext(), { wrapper: WrapperSearch }) + const { result: resultUseParseQuery } = renderHook(() => resultUseSearchContext.current.useParseQuery(), {}) + const parseQuery = resultUseParseQuery.current if (!error) { function customizer(a, b) { if (a instanceof Quantity) { return a.equal(b) } } - expect(isEqualWith(parseQuery(quantity, input, unitSystems.Custom.units), output, customizer)).toBe(true) + expect(isEqualWith(parseQuery(quantity, input), output, customizer)).toBe(true) } else { - expect(() => parseQuery(quantity, input, unitSystems.Custom.units)).toThrow(error) + expect(() => parseQuery(quantity, input)).toThrow(error) } } ) diff --git a/gui/src/components/search/conftest.spec.js b/gui/src/components/search/conftest.spec.js index cead5c0ea71b6a3e965d7a5423fa9952ebcf726f..7792d374268c78d581da81adc14d2b1cc975d793 100644 --- a/gui/src/components/search/conftest.spec.js +++ b/gui/src/components/search/conftest.spec.js @@ -28,7 +28,8 @@ import { SearchContext } from './SearchContext' import { defaultFilterData } from './FilterRegistry' import { format } from 'date-fns' import { DType } from '../../utils' -import { Unit, unitSystems } from '../../units' +import { Unit } from '../units/Unit' +import { ui } from '../../config' import { menuMap } from './menus/FilterMainMenu' /*****************************************************************************/ @@ -36,7 +37,7 @@ import { menuMap } from './menus/FilterMainMenu' /** * Render within a search context. */ -const WrapperSearch = ({children}) => { +export const WrapperSearch = ({children}) => { return <WrapperDefault> <SearchContext resource="entries"> {children} @@ -70,7 +71,7 @@ export async function expectFilterTitle(quantity, label, description, unit, disa const finalDescription = description || data?.description if (!disableUnit) { const finalUnit = unit || ( - data?.unit && new Unit(data?.unit).toSystem(unitSystems.Custom.units).label() + data?.unit && new Unit(data?.unit).toSystem(ui.unit_systems.options.Custom.units).label() ) if (finalUnit) finalLabel = `${finalLabel} (${finalUnit})` } diff --git a/gui/src/components/search/input/InputRange.js b/gui/src/components/search/input/InputRange.js index 822a8d6c1dc5d35150729296871c9d2d3df4f5bf..433fca03f3f1f87ca02f095841ecd1c3ee92f4a7 100644 --- a/gui/src/components/search/input/InputRange.js +++ b/gui/src/components/search/input/InputRange.js @@ -27,7 +27,9 @@ import InputHeader from './InputHeader' import InputTooltip from './InputTooltip' import { inputSectionContext } from './InputSection' import { InputTextField } from './InputText' -import { useUnits, Quantity, Unit } from '../../../units' +import { Quantity } from '../../units/Quantity' +import { Unit } from '../../units/Unit' +import { useUnitContext } from '../../units/UnitContext' import { DType, formatNumber } from '../../../utils' import { getInterval } from '../../plotting/common' import { dateFormat } from '../../../config' @@ -118,7 +120,7 @@ export const Range = React.memo(({ classes, 'data-testid': testID }) => { - const units = useUnits() + const {units} = useUnitContext() const {filterData, useAgg, useFilterState, useIsStatisticsEnabled} = useSearchContext() const sectionContext = useContext(inputSectionContext) const repeats = sectionContext?.repeats diff --git a/gui/src/components/search/input/InputText.js b/gui/src/components/search/input/InputText.js index 854f7ec6cf25a65e5e818cfa86456f18748d0dd6..6c99cd0e2ac78cfd281a710606fe24db839ceb20 100644 --- a/gui/src/components/search/input/InputText.js +++ b/gui/src/components/search/input/InputText.js @@ -18,14 +18,13 @@ import React, { useCallback, useState, useMemo, useRef } from 'react' import { makeStyles, useTheme } from '@material-ui/core/styles' import PropTypes from 'prop-types' -import { useRecoilValue } from 'recoil' import clsx from 'clsx' import { CircularProgress, Tooltip, IconButton, TextField } from '@material-ui/core' import Autocomplete from '@material-ui/lab/Autocomplete' +import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown' import CloseIcon from '@material-ui/icons/Close' import { isNil } from 'lodash' import { useSearchContext } from '../SearchContext' -import { guiState } from '../../GUIMenu' import { useSuggestions } from '../../../hooks' import { searchQuantities } from '../../../config' import Placeholder from '../../visualization/Placeholder' @@ -40,13 +39,11 @@ const useInputTextFieldStyles = makeStyles(theme => ({ })) export const InputTextField = React.memo((props) => { const initialLabel = useState(props.label)[0] - const inputVariant = useRecoilValue(guiState('inputVariant')) - const inputSize = useRecoilValue(guiState('inputSize')) const styles = useInputTextFieldStyles({classes: props.classes}) return props.loading ? <Placeholder className={clsx(props.className, styles.root)} /> - : <TextField size={inputSize} variant={inputVariant} {...props} hiddenLabel={!initialLabel}/> + : <TextField size="small" variant="filled" {...props} hiddenLabel={!initialLabel}/> }) InputTextField.propTypes = { @@ -57,7 +54,8 @@ InputTextField.propTypes = { } /* - * Generic text field component that should be used for most user inputs. + * Customized version of Autocomplete with custom NOMAD styling and behaviour. + * * Defines default behaviour for user input such as clearing inputs when * pressing esc and submitting values when pressing enter. Can also display * customizable list of suggestions. @@ -71,6 +69,16 @@ const useInputTextStyles = makeStyles(theme => ({ flexDirection: 'column', boxSizing: 'border-box' }, + popupIndicatorOpen: { + transform: 'rotate(180deg)' + }, + adornmentList: { + display: 'flex', + alignItems: 'center' + }, + adornment: { + padding: '3px' + }, listbox: { boxSizing: 'border-box', '& ul': { @@ -89,22 +97,28 @@ export const InputText = React.memo(({ onAccept, onSelect, onBlur, + onFocus, onError, getOptionLabel, groupBy, renderOption, renderGroup, + suggestAllOnFocus, + showOpenSuggestions, ListboxComponent, filterOptions, className, classes, TextFieldProps, InputProps, - PaperComponent + PaperComponent, + disableClearable, + disableAcceptOnBlur }) => { const theme = useTheme() const styles = useInputTextStyles({classes: classes, theme: theme}) const [open, setOpen] = useState(false) + const [suggestAll, setSuggestAll] = useState(false) const disabled = TextFieldProps?.disabled // The highlighted item is stored in a ref to keep the component more // responsive during browsing the suggestions @@ -112,8 +126,8 @@ export const InputText = React.memo(({ // Clears the input value and closes suggestions list const clearInputValue = useCallback(() => { - onError && onError(undefined) - onChange && onChange("") + onError?.(undefined) + onChange?.("") setOpen(false) }, [onChange, onError]) @@ -124,9 +138,9 @@ export const InputText = React.memo(({ // Handle blur const handleBlur = useCallback(() => { - onBlur && onBlur() - onAccept && onAccept(value) - }, [onBlur, onAccept, value]) + onBlur?.() + !disableAcceptOnBlur && onAccept?.(value) + }, [onBlur, onAccept, value, disableAcceptOnBlur]) // Handles special key presses const handleKeyDown = useCallback((event) => { @@ -145,9 +159,9 @@ export const InputText = React.memo(({ // or if menu is not open submit the value. if (event.key === 'Enter') { if (open && highlightRef.current) { - onSelect && onSelect(getOptionLabel(highlightRef.current).trim()) + onSelect?.(getOptionLabel(highlightRef.current).trim()) } else { - onAccept && onAccept(value && value.trim()) + onAccept?.(value && value.trim()) } event.stopPropagation() event.preventDefault() @@ -158,12 +172,13 @@ export const InputText = React.memo(({ // Handle input events. Errors are cleaned in input change, regular typing // emits onChange, selection with mouse emits onSelect. const handleInputChange = useCallback((event, value, reason) => { + setSuggestAll(false) onError && onError(undefined) if (event) { if (reason === 'reset') { - onSelect && onSelect(value) + onSelect?.(value) } else { - onChange && onChange(value) + onChange?.(value) } } }, [onChange, onSelect, onError]) @@ -191,8 +206,12 @@ export const InputText = React.memo(({ getOptionSelected={(option, value) => false} groupBy={groupBy} renderGroup={renderGroup} - filterOptions={filterOptions} + filterOptions={suggestAll + ? (opt) => opt + : filterOptions + } renderOption={renderOption} + selectOnFocus={true} renderInput={(params) => { // We need to strip out the styling of the input field that is imposed // by Autocomplete. Otherwise the styles enabled by the @@ -203,25 +222,41 @@ export const InputText = React.memo(({ size="small" helperText={error || undefined} error={!!error} + onFocus={() => { suggestAllOnFocus && setSuggestAll(true); onFocus?.() } } onKeyDown={handleKeyDown} InputLabelProps={{shrink}} InputProps={{ ...params.InputProps, - endAdornment: (<> - {loading ? <CircularProgress color="inherit" size={20} /> : null} - {(value?.length || null) && <> - <Tooltip title="Clear"> - <IconButton + endAdornment: (<div className={styles.adornmentList}> + {loading ? <CircularProgress color="inherit" size={20} className={styles.adornment} /> : null} + {(value?.length && !disableClearable) + ? <Tooltip title="Clear"> + <IconButton + size="small" + disabled={disabled} + onClick={clearInputValue} + className={styles.iconButton} + aria-label="clear" + > + <CloseIcon/> + </IconButton> + </Tooltip> + : null + } + {(showOpenSuggestions) + ? <IconButton size="small" - onClick={clearInputValue} - className={styles.iconButton} - aria-label="clear" + disabled={disabled} + onClick={() => setOpen(old => !old)} + className={clsx(styles.popupIndicator, { + [styles.popupIndicatorOpen]: open + })} > - <CloseIcon/> - </IconButton> - </Tooltip> - </>} - </>), + <ArrowDropDownIcon /> + </IconButton> + : null + } + </div>), ...InputProps }} {...TextFieldProps} @@ -241,6 +276,7 @@ InputText.propTypes = { onSelect: PropTypes.func, // Triggered when an option is selected from suggestions onAccept: PropTypes.func, // Triggered when value should be accepted onBlur: PropTypes.func, // Triggered when text goes out of focus + onFocus: PropTypes.func, // Triggered when text is focused onError: PropTypes.func, // Triggered when any errors should be cleared getOptionLabel: PropTypes.func, groupBy: PropTypes.func, @@ -251,12 +287,17 @@ InputText.propTypes = { TextFieldProps: PropTypes.object, InputProps: PropTypes.object, filterOptions: PropTypes.func, + disableClearable: PropTypes.bool, + disableAcceptOnBlur: PropTypes.bool, + suggestAllOnFocus: PropTypes.bool, // Whether to provide all suggestion values when input is focused + showOpenSuggestions: PropTypes.bool, // Whether to show button for opening suggestions className: PropTypes.string, classes: PropTypes.object } InputText.defaultProps = { - getOptionLabel: (option) => option.value + getOptionLabel: (option) => option.value, + showOpenSuggestions: false } /* diff --git a/gui/src/components/search/widgets/WidgetScatterPlot.js b/gui/src/components/search/widgets/WidgetScatterPlot.js index 001220c5a15a5caceab76a3016fc42cdc70eb0ad..0b8d175e10a70be927db7de5d1b69d8e33453943 100644 --- a/gui/src/components/search/widgets/WidgetScatterPlot.js +++ b/gui/src/components/search/widgets/WidgetScatterPlot.js @@ -40,7 +40,9 @@ import { CropFree, PanTool, Fullscreen, Replay } from '@material-ui/icons' import { autorangeDescription } from './WidgetHistogram' import { styled } from '@material-ui/core/styles' import { DType } from '../../../utils' -import { Quantity, Unit, useUnits } from '../../../units' +import { Quantity } from '../../units/Quantity' +import { Unit } from '../../units/Unit' +import { useUnitContext } from '../../units/UnitContext' // Predefined in order to not break memoization const dtypesNumeric = new Set([DType.Int, DType.Float]) @@ -91,7 +93,7 @@ export const WidgetScatterPlot = React.memo(( onSelected }) => { const styles = useStyles() - const units = useUnits() + const {units} = useUnitContext() const canvas = useRef() const [float, setFloat] = useState(false) const [loading, setLoading] = useState(true) diff --git a/gui/src/components/units/Quantity.js b/gui/src/components/units/Quantity.js new file mode 100644 index 0000000000000000000000000000000000000000..803e884c7a31e9249cebe1c1d3c9507586cbbfd4 --- /dev/null +++ b/gui/src/components/units/Quantity.js @@ -0,0 +1,153 @@ +/* + * 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 {isNumber, isArray, isNil} from 'lodash' +import {Unit} from './Unit' +import {mapDeep} from '../../utils' + +/** + * Class for persisting persisting a numeric value together with unit + * information. + */ +export class Quantity { + /** + * @param {number | n-dimensional array of numbers} value Numeric value. See + * also the argument 'normalized'. + * @param {str | Unit} unit Unit for the quantity. + * @param {boolean} normalized Whether the given numeric value is already + * normalized to base units. + */ + constructor(value, unit, normalized = false) { + this.unit = new Unit(unit) + if (!isNumber(value) && !isArray(value)) { + throw Error('Please provide the the value 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. + this.normalized_value = normalized ? value : this.normalize(value) + } + + /** + * Get value in current units. + * @returns The numeric value in the currently set units. + */ + value() { + return this.denormalize(this.normalized_value) + } + + /** + * Convert value from currently set units to base units. + * @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)) + } + + /** + * Convert value from base units to currently set units. + * @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)) + } + + label() { + return this.unit.label() + } + + dimension(base) { + return this.unit.dimension(base) + } + + to(unit) { + return new Quantity(this.normalized_value, this.unit.to(unit), true) + } + + toSI() { + return new Quantity(this.normalized_value, this.unit.toSI(), true) + } + + toSystem(system) { + return new Quantity(this.normalized_value, this.unit.toSystem(system), true) + } + + /** + * Checks if the given Quantity is equal to this one. + * @param {Quantity} quantity Quantity to compare to + * @returns boolean Whether quantities are equal + */ + equal(quantity) { + if (quantity instanceof Quantity) { + return this.normalized_value === quantity.normalized_value && this.unit.equalBase(quantity.unit) + } else { + throw Error('The given value is not an instance of Quantity.') + } + } +} + +/** + * Convenience function for parsing value and unit information from a string. + * + * @param {string} input The input string to parse + * @param {boolean} requireValue Whether a value is required. + * @param {boolean} requireUnit Whether a unit is required. + * @param {string} dimension Dimension for the unit. Nil value means a + * dimensionless unit. + * @returns Object containing the following properties, if available: + * - value: Numerical value as a number + * - valueString: Numerical value as a string + * - unit: Unit instance + * - unitString: Unit as a string + * - error: Error messsage + */ +export function parseQuantity(input, requireValue = true, requireUnit = true, dimension = undefined) { + input = input.trim() + const valueString = input.match(/^[+-]?((\d+\.\d+|\d+\.|\.\d?|\d+)(e|e\+|e-)\d+|(\d+\.\d+|\d+\.|\.\d?|\d+))?/)?.[0] + if (requireValue && isNil(valueString)) { + return {error: 'Enter a valid numerical value'} + } + const value = Number(valueString) + const unitString = input.substring(valueString.length).trim() + const dim = isNil(dimension) ? 'dimensionless' : dimension + if (unitString === '' && dim !== 'dimensionless' && requireUnit) { + return {value, valueString, unitString, error: 'Unit is required'} + } + if (unitString === '' && !requireUnit) { + return {value, valueString, unitString} + } + if (dim === 'dimensionless' && unitString !== '') { + return {value, valueString, unitString, error: 'Enter a numerical value without units'} + } + let unit + try { + unit = new Unit(dim === 'dimensionless' ? 'dimensionless' : input) + } catch { + return {valueString, value, unitString, error: `Unit "${unitString}" is not available`} + } + const inputDim = unit.dimension(false) + if (inputDim !== dimension) { + return {valueString, value, unitString, unit, error: `Unit "${unitString}" has incompatible dimension`} + } + return {value, valueString, unit, unitString} +} diff --git a/gui/src/components/units/Quantity.spec.js b/gui/src/components/units/Quantity.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e0a1f225eed595c1df0780b2521b305f5c1e5251 --- /dev/null +++ b/gui/src/components/units/Quantity.spec.js @@ -0,0 +1,123 @@ +/* + * 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 { Quantity } from './Quantity' +import { dimensionMap } from './UnitContext' + +test('conversion works both ways for each compatible unit', async () => { + // Create a list of all possible conversions + const conversions = [] + for (const dimension of Object.values(dimensionMap)) { + const units = dimension.units + for (const unitA of units) { + for (const unitB of units) { + conversions.push([unitA, unitB]) + } + } + } + for (const [unitA, unitB] of conversions) { + const a = new Quantity(1, unitA) + const b = a.to(unitB) + const c = b.to(unitA) + expect(a.value()).toBeCloseTo(c.value(), 10) + } +}) + +test.each([ + ['same unit', 'kelvin', 'kelvin', 1, 1], + ['temperature celsius', 'kelvin', 'celsius', 1, -272.15], + ['temperature fahrenheit', 'kelvin', 'fahrenheit', 1, -457.87], + ['abbreviated name', 'J', 'eV', 1, 6241509074460763000], + ['full name', 'joule', 'electron_volt', 1, 6241509074460763000], + ['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 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 (multiple)', 'delta_celsius / delta_celsius', 'delta_K / delta_K', 1, 1], + ['explicit delta symbol (single)', 'Δcelsius', 'ΔK', 1, 274.15], + ['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], + ['simple to complex with one unit', 'N', 'kg*m/s^2', 1, 1], + ['complex to simple with one unit', 'kg*m/s^2', 'N', 1, 1], + ['simple to complex with expression', 'N/m', 'kg/s^2', 1, 1], + ['complex to simple with expression', 'kg/s^2', 'N/m', 1, 1], + ['unit starting with a number', '1/minute', '1/second', 1, 0.016666666666666666] +] +)('test conversion with "to()": %s', async (name, unitA, unitB, valueA, valueB) => { + const a = new Quantity(valueA, unitA) + const b = a.to(unitB) + expect(b.value()).toBeCloseTo(valueB, 10) +}) + +test.each([ + ['conversion with single unit', 'meter', {length: {definition: 'angstrom'}}, 1, 1e10], + ['conversion with power', 'meter^2', {length: {definition: 'angstrom'}}, 1, 99999999999999980000], + ['do not simplify', 'gram*angstrom/fs^2', {mass: {definition: 'kilogram'}, length: {definition: 'meter'}, time: {definition: 'second'}}, 1, 99999999999999980], + ['do not convert to base', 'eV', {energy: {definition: 'joule'}}, 1, 1.602176634e-19], + ['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] +] +)('test conversion with "toSystem()": %s', async (name, unit, system, valueA, valueB) => { + const a = new Quantity(valueA, unit) + const b = a.toSystem(system) + expect(b.value()).toBeCloseTo(valueB, 10) +}) + +test.each([ + ['celsius to kelvin', 'celsius', 'kelvin', 5, 278.15], + ['fahrenheit to kelvin', 'fahrenheit', 'kelvin', 5, 258.15], + ['celsius to fahrenheit', 'celsius', 'fahrenheit', 5, 41], + ['celsius to kelvin: derived unit (implicit delta)', 'joule/celsius', 'joule/kelvin', 5, 5], + ['celsius to kelvin: derived unit (explicit delta)', 'joule/delta_celsius', 'joule/kelvin', 5, 5], + ['fahrenheit to kelvin: derived unit (offset not applied)', 'joule/fahrenheit', 'joule/kelvin', 5, 9 / 5 * 5], + ['celsius to fahrenheit: derived unit (offset not applied)', 'joule/celsius', 'joule/fahrenheit', 5, 5 / 9 * 5] +] +)('test temperature conversion": %s', async (name, unitA, unitB, valueA, valueB) => { + const a = new Quantity(valueA, unitA) + const b = a.to(unitB) + const c = b.to(unitA) + expect(b.value()).toBeCloseTo(valueB, 10) + expect(c.value()).toBeCloseTo(valueA, 10) +}) + +test.each([ + [0], + [1], + [2], + [3] +] +)('test different value dimensions: %sD', async (dimension) => { + let value = 1 + for (let i = 0; i < dimension; ++i) { + value = [value] + } + const a = new Quantity(value, 'angstrom') + const b = a.to('nanometer') + let valueA = a.value() + let valueB = b.value() + for (let i = 0; i < dimension; ++i) { + valueA = valueA[0] + valueB = valueB[0] + } + expect(valueA).toBeCloseTo(10 * valueB) +}) diff --git a/gui/src/components/units/Unit.js b/gui/src/components/units/Unit.js new file mode 100644 index 0000000000000000000000000000000000000000..d0a3e25307dd85036bcce5edcf0641a3a79e9cea --- /dev/null +++ b/gui/src/components/units/Unit.js @@ -0,0 +1,364 @@ +/* + * 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 {isNil, has, isString} from 'lodash' +import {Unit as UnitMathJS} from 'mathjs' +import {unitToAbbreviationMap} from './UnitContext' + +/** + * Helper class for persisting unit information. + * + * Builds upon the math.js Unit class system, but adds additional functionality, + * including: + * - Ability to convert to any unit system given as an argument + * - Abbreviated labels for dense formatting + */ +export class Unit { + /** + * @param {str | Unit} unit Unit for the quantity. + */ + constructor(unit) { + if (isString(unit)) { + unit = this.normalizeExpression(unit) + unit = new UnitMathJS(undefined, unit) + } else if (unit instanceof Unit) { + unit = unit.mathjsUnit.clone() + } else if (unit instanceof UnitMathJS) { + unit = unit.clone() + } else { + throw Error('Please provide the unit as a string or as an instance of Unit.') + } + this.mathjsUnit = unit + // this._labelabbreviate = undefined + // this._label = undefined + } + + /** + * 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 + */ + normalizeExpression(expression) { + let normalized = expression.replace(/\*\*/g, '^') + normalized = normalized.replace(/delta_/g, '') + normalized = normalized.replace(/Δ/g, '') + return normalized + } + + /** + * Checks if the given unit has the same base dimensions as this one. + * @param {str | Unit} unit Unit to compare to + * @returns boolean Whether the units have the same base dimensions. + */ + equalBase(unit) { + if (isString(unit)) { + unit = this.normalizeExpression(unit) + unit = new Unit(unit) + } + return this.mathjsUnit.equalBase(unit.mathjsUnit) + } + + /** + * Used to create a human-readable description of the unit as a string. + * + * @param {bool} abbreviate Whether to abbreviate the label using the + * abbreviations for each unit and prefix. If false, the original unit names + * (as given or defined by the unit system) are used. + * @returns A string representing the unit. + */ + label(abbreviate = true) { + // 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 getName(unit) { + if (unit.base.key === 'dimensionless') return '' + return abbreviate + ? unitToAbbreviationMap?.[unit.name] || unit.name + : unit.name + } + + function getPrefix(unit, original) { + if (!abbreviate) return original + const prefixMap = { + // SI + deca: 'da', + hecto: 'h', + kilo: 'k', + mega: 'M', + giga: 'G', + tera: 'T', + peta: 'P', + exa: 'E', + zetta: 'Z', + yotta: 'Y', + deci: 'd', + centi: 'c', + milli: 'm', + micro: 'u', + nano: 'n', + pico: 'p', + femto: 'f', + atto: 'a', + zepto: 'z', + yocto: 'y', + // IEC + kibi: 'Ki', + mebi: 'Mi', + gibi: 'Gi', + tebi: 'Ti', + pebi: 'Pi', + exi: 'Ei', + zebi: 'Zi', + yobi: 'Yi' + } + return prefixMap?.[original] || original + } + + for (let i = 0; i < units.length; i++) { + if (units[i].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 + } + } else if (units[i].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) + if (nNum > 0) { + strDen += ` ${prefix}${name}` + if (Math.abs(units[i].power + 1.0) > 1e-15) { + strDen += '^' + (-units[i].power) + } + } else { + strDen += ` ${prefix}${name}` + strDen += '^' + (units[i].power) + } + } + } + } + // Remove leading whitespace + strNum = strNum.substr(1) + strDen = strDen.substr(1) + + // Add parentheses for better copy/paste back into evaluate, for example, or + // for better pretty print formatting + if (nNum > 1 && nDen > 0) { + strNum = '(' + strNum + ')' + } + if (nDen > 1 && nNum > 0) { + strDen = '(' + strDen + ')' + } + + let str = strNum + if (nNum > 0 && nDen > 0) { + str += ' / ' + } + str += strDen + + // this._labelabbreviate = abbreviate + // this._label = str + return str + } + + /** + * Gets the dimension of this unit as a string. The order of the dimensions is + * fixed (determined at unit registration time). + * + * @param {boolean} base Whether to return dimension in base units. Otherwise + * the original unit dimensions are used. + * @returns The dimensionality as a string, e.g. 'time^2 energy mass^-2' + */ + dimension(base = true) { + const dimensions = Object.keys(UnitMathJS.BASE_UNITS) + const dimensionMap = Object.fromEntries(dimensions.map(name => [name, 0])) + + if (base) { + const BASE_DIMENSIONS = UnitMathJS.BASE_DIMENSIONS + for (let i = 0; i < BASE_DIMENSIONS.length; ++i) { + const power = this?.mathjsUnit.dimensions?.[i] + if (power) { + dimensionMap[BASE_DIMENSIONS[i]] += power + } + } + } else { + for (const unit of this?.mathjsUnit.units) { + const power = unit.power + if (power) { + dimensionMap[unit.unit.base.key] += power + } + } + } + return Object.entries(dimensionMap) + .filter(d => d[1] !== 0) + .map(d => `${d[0]}${((d[1] < 0 || d[1] > 1) && `^${d[1]}`) || ''}`).join(' ') + } + + /** + * Function for converting to another unit. + * + * @param {str | Unit} unit The target unit + * @returns A new Unit expressed in the given units. + */ + to(unit) { + if (isString(unit)) { + unit = this.normalizeExpression(unit) + } else if (unit instanceof Unit) { + unit = unit.label() + } else { + throw Error('Unknown unit type. Please provide the unit as as string or as instance of Unit.') + } + + // 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) + return new Unit(this.mathjsUnit.to(unit)) + } + + /** + * Function for converting the value of this Unit to the SI unit system. + * + * @returns A new Unit instance in the SI unit system. + */ + toSI() { + return this.toSystem({ + "dimensionless": { "definition": "dimensionless" }, + "length": { "definition": "m" }, + "mass": { "definition": "kg" }, + "time": { "definition": "s" }, + "current": { "definition": "A" }, + "temperature": { "definition": "K" }, + "luminosity": { "definition": "cd" }, + "luminous_flux": { "definition": "lm" }, + "substance": { "definition": "mol" }, + "angle": { "definition": "rad" }, + "information": { "definition": "bit" }, + "force": { "definition": "N" }, + "energy": { "definition": "J" }, + "power": { "definition": "W" }, + "pressure": { "definition": "Pa" }, + "charge": { "definition": "C" }, + "resistance": { "definition": "Ω" }, + "conductance": { "definition": "S" }, + "inductance": { "definition": "H" }, + "magnetic_flux": { "definition": "Wb" }, + "magnetic_field": { "definition": "T" }, + "frequency": { "definition": "Hz" }, + "luminance": { "definition": "nit" }, + "illuminance": { "definition": "lx" }, + "electric_potential": { "definition": "V" }, + "capacitance": { "definition": "F" }, + "activity": { "definition": "kat" } + }) + } + + /** + * Function for converting the value of this unit to another unit system. + * + * Notice that converting a unit to another unit system is not as easy as + * conversions to a specific unit. When converting to a specific unit one can + * simply check that the dimensions match and go ahead with the conversion. + * With unit systems, there can be multiple alternative forms, and choosing a + * good one is more difficult. E.g. should 'a_u_force * angstrom' be converted + * into: + * + * a) N m + * b) J + * c) (kg m^2) / s^2 + * + * By default this function will try to preserve the original unit dimensions + * and not convert everything down to base units. If a derived unit is not + * present, it will, however, attempt to convert it to the base units. Any + * further simplication is not performed. + * + * @param {object} system The target unit system. + * @returns A new Unit instance in the given system. + */ + toSystem(system) { + // Go through the currently defined units, identify their dimension and look + // for the corresponding dimension in the given unit system. If one is + // present, convert to it. Otherwise convert to base dimensions. + const UNITS = UnitMathJS.UNITS + const PREFIXES = UnitMathJS.PREFIXES + const BASE_DIMENSIONS = UnitMathJS.BASE_DIMENSIONS + const BASE_UNITS = UnitMathJS.BASE_UNITS + const proposedUnitList = [] + 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}) + } + // Otherwise convert to base units + } else { + let missingBaseDim = false + const baseUnit = BASE_UNITS[dimension] + const newDimensions = baseUnit.dimensions + for (let i = 0; i < BASE_DIMENSIONS.length; i++) { + const baseDim = BASE_DIMENSIONS[i] + if (Math.abs(newDimensions[i] || 0) > 1e-12) { + if (has(system, baseDim)) { + proposedUnitList.push({ + unit: UNITS[system[baseDim].definition], + prefix: PREFIXES.NONE[''], + power: unit.power ? newDimensions[i] * unit.power : 0 + }) + } else { + missingBaseDim = true + } + } + } + if (missingBaseDim) { + throw Error(`The given unit system does not contain the required unit definitions for converting ${unit.name} with dimension ${dimension}.`) + } + } + } + + const ret = this.mathjsUnit.clone() + ret.units = proposedUnitList + return new Unit(ret) + } +} diff --git a/gui/src/units.spec.js b/gui/src/components/units/Unit.spec.js similarity index 59% rename from gui/src/units.spec.js rename to gui/src/components/units/Unit.spec.js index 15255523f19d58a94bda03adcdb0a870189bf79f..851b28246b3e9489a5daee9631e5c9b67a5ebb91 100644 --- a/gui/src/units.spec.js +++ b/gui/src/components/units/Unit.spec.js @@ -17,24 +17,25 @@ */ import { Unit as UnitMathJS } from 'mathjs' -import { Quantity, dimensionMap, unitMap } from './units' +import { Unit } from './Unit' +import { unitMap } from './UnitContext' test('each unit can be created using its full name, alias or short form (+ all available prefixes)', async () => { for (const [name, def] of Object.entries(unitMap)) { // Full name + prefixes - expect(new Quantity(1, name)).not.toBeNaN() + expect(new Unit(name)).not.toBeNaN() if (def.prefixes) { for (const prefix of Object.keys(UnitMathJS.PREFIXES[def.prefixes.toUpperCase()])) { - expect(new Quantity(1, `${prefix}${name}`)).not.toBeNaN() + expect(new Unit(`${prefix}${name}`)).not.toBeNaN() } } // Aliases + prefixes if (def.aliases) { for (const alias of def.aliases) { - expect(new Quantity(1, alias)).not.toBeNaN() + expect(new Unit(alias)).not.toBeNaN() if (def.prefixes) { for (const prefix of Object.keys(UnitMathJS.PREFIXES[def.prefixes.toUpperCase()])) { - expect(new Quantity(1, `${prefix}${alias}`)).not.toBeNaN() + expect(new Unit(`${prefix}${alias}`)).not.toBeNaN() } } } @@ -42,25 +43,6 @@ test('each unit can be created using its full name, alias or short form (+ all a } }) -test('unit conversion works both ways for each compatible unit', async () => { - // Create a list of all possible conversions - const conversions = [] - for (const dimension of Object.values(dimensionMap)) { - const units = dimension.units - for (const unitA of units) { - for (const unitB of units) { - conversions.push([unitA, unitB]) - } - } - } - for (const [unitA, unitB] of conversions) { - const a = new Quantity(1, unitA) - const b = a.to(unitB) - const c = b.to(unitA) - expect(a.value()).toBeCloseTo(c.value(), 10) - } -}) - test.each([ ['dimensionless', 'dimensionless', ''], ['non-abbreviated', 'celsius', '°C'], @@ -76,10 +58,24 @@ test.each([ ['chain', 'meter*meter/second^2', '(m m) / s^2'] ] )('label abbreviation: %s', async (name, unit, label) => { - const a = new Quantity(1, unit) + const a = new Unit(unit) expect(a.label()).toBe(label) }) +test.each([ + ['dimensionless', 'dimensionless', 'dimensionless'], + ['single unit', 'meter', 'length'], + ['fixed order 1', 'meter * second', 'length time'], + ['fixed order 2', 'second * meter', 'length time'], + ['power', 'meter^3 * second^-1', 'length^3 time^-1'], + ['in derived', 'joule', 'energy', false], + ['in base', 'joule', 'mass length^2 time^-2'] +] +)('test getting dimension": %s', async (name, unit, dimension, base = true) => { + const a = new Unit(unit) + expect(a.dimension(base)).toBe(dimension) +}) + test.each([ ['same unit', 'kelvin', 'kelvin', 'K'], ['temperature celsius', 'kelvin', 'celsius', '°C'], @@ -104,80 +100,27 @@ test.each([ ['unit starting with a number', '1/minute', '1/second', 's^-1'] ] )('test conversion with "to()": %s', async (name, unitA, unitB, labelB) => { - const a = new Quantity(1, unitA) + const a = new Unit(unitA) const b = a.to(unitB) - expect(b.value()).not.toBeNaN() expect(b.label()).toBe(labelB) }) test.each([ - ['conversion with single unit', 'meter', {length: {name: 'angstrom'}}, 'Å'], - ['conversion with power', 'meter^2', {length: {name: 'angstrom'}}, 'Å^2'], - ['do not simplify', 'gram*angstrom/fs^2', {mass: {name: 'kilogram'}, length: {name: 'meter'}, time: {name: 'second'}}, '(kg m) / s^2'], - ['do not convert to base', 'eV', {energy: {name: 'joule'}}, 'J'], - ['combination', 'a_u_force * angstrom', {force: {name: 'newton'}, length: {name: 'meter'}}, 'N m'], - ['use base units if derived unit not defined in system', 'newton * meter', {mass: {name: 'kilogram'}, time: {name: 'second'}, length: {name: 'meter'}}, '(kg m m) / s^2'] + ['conversion with single unit', 'meter', {length: {definition: 'angstrom'}}, 'Å'], + ['conversion with power', 'meter^2', {length: {definition: 'angstrom'}}, 'Å^2'], + ['do not simplify', 'gram*angstrom/fs^2', {mass: {definition: 'kilogram'}, length: {definition: 'meter'}, time: {definition: 'second'}}, '(kg m) / s^2'], + ['do not convert to base', 'eV', {energy: {definition: 'joule'}}, 'J'], + ['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'] ] )('test conversion with "toSystem()": %s', async (name, unit, system, label) => { - const a = new Quantity(1, unit) + const a = new Unit(unit) const b = a.toSystem(system) - expect(b.value()).not.toBeNaN() expect(b.label()).toBe(label) }) -test.each([ - ['dimensionless', 'dimensionless', 'dimensionless'], - ['single unit', 'meter', 'length'], - ['fixed order 1', 'meter * second', 'length time'], - ['fixed order 2', 'second * meter', 'length time'], - ['power', 'meter^3 * second^-1', 'length^3 time^-1'], - ['in derived', 'joule', 'energy', false], - ['in base', 'joule', 'mass length^2 time^-2'] -] -)('test getting dimension": %s', async (name, unit, dimension, base = true) => { - const a = new Quantity(1, unit) - expect(a.dimension(base)).toBe(dimension) -}) - -test.each([ - ['celsius to kelvin', 'celsius', 'kelvin', 5, 278.15], - ['fahrenheit to kelvin', 'fahrenheit', 'kelvin', 5, 258.15], - ['celsius to fahrenheit', 'celsius', 'fahrenheit', 5, 41], - ['celsius to kelvin: derived unit (implicit delta)', 'joule/celsius', 'joule/kelvin', 5, 5], - ['celsius to kelvin: derived unit (explicit delta)', 'joule/delta_celsius', 'joule/kelvin', 5, 5], - ['fahrenheit to kelvin: derived unit (offset not applied)', 'joule/fahrenheit', 'joule/kelvin', 5, 9 / 5 * 5], - ['celsius to fahrenheit: derived unit (offset not applied)', 'joule/celsius', 'joule/fahrenheit', 5, 5 / 9 * 5] -] -)('test temperature conversion": %s', async (name, unitA, unitB, valueA, valueB) => { - const a = new Quantity(valueA, unitA) - const b = a.to(unitB) - const c = b.to(unitA) - expect(b.value()).toBeCloseTo(valueB, 10) - expect(c.value()).toBeCloseTo(valueA, 10) -}) - -test.each([ - [0], - [1], - [2], - [3] -] -)('test different value dimensions: %sD', async (dimension) => { - let value = 1 - for (let i = 0; i < dimension; ++i) { - value = [value] - } - const a = new Quantity(value, 'angstrom') - const b = a.to('nanometer') - let valueA = a.value() - let valueB = b.value() - for (let i = 0; i < dimension; ++i) { - valueA = valueA[0] - valueB = valueB[0] - } - expect(valueA).toBeCloseTo(10 * valueB) -}) - test.each([ ['incompatible dimensions', 'm', 'J'], ['wrong power of the correct dimension', 'm', 'm^2'], @@ -187,6 +130,6 @@ test.each([ ] )('invalid conversions with "to()": %s', async (name, unitA, unitB) => { expect(() => { - new Quantity(1, unitA).to(unitB) + new Unit(unitA).to(unitB) }).toThrow() }) diff --git a/gui/src/components/units/UnitContext.js b/gui/src/components/units/UnitContext.js new file mode 100644 index 0000000000000000000000000000000000000000..5fff965cb2d0e27b6295eaccef38bd1130546f90 --- /dev/null +++ b/gui/src/components/units/UnitContext.js @@ -0,0 +1,157 @@ +/* + * 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 React, { useState, useMemo, useContext, useCallback } from 'react' +import PropTypes from 'prop-types' +import { isNil, isFunction, startCase, toLower, cloneDeep } from 'lodash' +import { Unit as UnitMathJS, createUnit } from 'mathjs' +import { unitList, unitPrefixes as prefixes } from '../../config' + +// Delete all units and prefixes that come by default with Math.js. This way +// they cannot be intermixed with the NOMAD units. Notice that we have to clear +// them in place: they are defined as const. +Object.keys(UnitMathJS.UNITS).forEach(name => UnitMathJS.deleteUnit(name)) +UnitMathJS.BASE_DIMENSIONS.splice(0, UnitMathJS.BASE_DIMENSIONS.length) +Object.getOwnPropertyNames(UnitMathJS.BASE_UNITS).forEach(function(prop) { + delete UnitMathJS.BASE_UNITS[prop] +}) +Object.getOwnPropertyNames(UnitMathJS.PREFIXES).forEach(function(prop) { + delete UnitMathJS.PREFIXES[prop] +}) +UnitMathJS.PREFIXES.NONE = {'': { name: '', value: 1, scientific: true }} +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(['_', 'Å', 'Å', 'å', '°', 'µ', 'ö', 'é', '∞']) + return specialChars.has(c) +} +const isGreekLetter = function(c) { + const charCode = c.charCodeAt(0) + return (charCode > 912 && charCode < 970) +} +UnitMathJS.isValidAlpha = function(c) { + return isAlphaOriginal(c) || isSpecialChar(c) || isGreekLetter(c) +} + +// Create MathJS unit definitions from the data exported by 'nomad dev units' +export const unitToAbbreviationMap = {} +const unitDefinitions = {} +for (let def of unitList) { + const name = def.name + def = { + ...def, + baseName: def.dimension, + prefixes: 'pint' + } + unitDefinitions[name] = def + if (def.abbreviation) { + unitToAbbreviationMap[name] = def.abbreviation + if (def.aliases) { + for (const alias of def.aliases) { + unitToAbbreviationMap[alias] = def.abbreviation + } + } + } +} +createUnit(unitDefinitions, {override: true}) + +// Export unit options for each unit and dimension +export const unitMap = Object.fromEntries(unitList.map(x => [x.name, x])) +export const dimensionMap = {} +for (const def of unitList) { + const name = def.name + const dimension = def.dimension + if (isNil(dimension)) { + continue + } + const oldInfo = dimensionMap[dimension] || { + label: startCase(toLower(dimension.replace('_', ' '))) + } + const oldList = oldInfo.units || [] + oldList.push(name) + oldInfo.units = oldList + dimensionMap[dimension] = oldInfo +} + +/** + * Convenience function for getting compatible units for a given dimension. + * Returns all compatible units that have been registered. + * + * @param {string} dimension The dimension. + * @returns Array of compatible units. + */ +export function getUnits(dimension) { + return dimensionMap?.[dimension]?.units || [] +} + +/** + * React context for interacting with unit configurations. + */ +export const unitContext = React.createContext() +export const UnitProvider = React.memo(({initialUnitSystems, initialSelected, children}) => { + const resetUnitSystems = useState(cloneDeep(initialUnitSystems))[0] + const [unitSystems, setUnitSystems] = useState(cloneDeep(initialUnitSystems)) + const [selected, setSelected] = useState(initialSelected) + + const reset = useCallback(() => { + setUnitSystems(cloneDeep(resetUnitSystems)) + }, [resetUnitSystems]) + + const values = useMemo(() => { + return { + units: unitSystems[selected].units, + setUnits: (value) => { + setUnitSystems(old => { + const newSystems = {...old} + newSystems[selected].units = isFunction(value) + ? value(newSystems[selected].units) + : value + return newSystems + }) + }, + unitSystems, + unitMap, + dimensionMap, + selected, + setSelected, + reset + } + }, [unitSystems, selected, reset]) + + return <unitContext.Provider value={values}> + {children} + </unitContext.Provider> +}) + +UnitProvider.propTypes = { + initialUnitSystems: PropTypes.object, + initialSelected: PropTypes.string, + children: PropTypes.node +} + +/** + * Convenience hook for using the current unit context. + * + * @returns Object containing the currently set units for each dimension (e.g. + * {energy: 'joule'}) + */ +export const useUnitContext = () => { + return useContext(unitContext) +} diff --git a/gui/src/components/units/UnitContext.spec.js b/gui/src/components/units/UnitContext.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3654a17d47761c06f1b3b9ab5fae10aaddf9c729 --- /dev/null +++ b/gui/src/components/units/UnitContext.spec.js @@ -0,0 +1,57 @@ +/* + * 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 React from 'react' +import { renderHook, act } from '@testing-library/react-hooks' +import { UnitProvider, useUnitContext } from './UnitContext' + +const wrapper = ({ children }) => <UnitProvider + initialUnitSystems={{ + SI: {units: {'length': {'definition': 'meter'}}}, + AU: {units: {'length': {'definition': 'meter'}}} + }} + initialSelected='SI' +> + {children} +</UnitProvider> + +test('the initial selection is returned correctly', () => { + const { result } = renderHook(() => useUnitContext(), { wrapper }) + expect(result.current.selected).toBe('SI') +}) + +test('updating unit system selection works', () => { + const { result } = renderHook(() => useUnitContext(), { wrapper }) + act(() => { + result.current.setSelected('AU') + }) + expect(result.current.selected).toBe('AU') +}) + +test('the initial units are returned correctly', () => { + const { result } = renderHook(() => useUnitContext(), { wrapper }) + expect(result.current.units.length.definition).toBe('meter') +}) + +test('updating units works', () => { + const { result } = renderHook(() => useUnitContext(), { wrapper }) + act(() => { + result.current.setUnits(old => ({...old, length: {definition: 'mm'}})) + }) + expect(result.current.units.length.definition).toBe('mm') +}) diff --git a/gui/src/components/units/UnitDimensionSelect.js b/gui/src/components/units/UnitDimensionSelect.js new file mode 100644 index 0000000000000000000000000000000000000000..aa7fdbba778f794e77015bd86f6fd267614b24cb --- /dev/null +++ b/gui/src/components/units/UnitDimensionSelect.js @@ -0,0 +1,90 @@ +/* + * 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 React, { useState, useEffect, useCallback, useRef } from 'react' +import PropTypes from 'prop-types' +import {useUnitContext} from './UnitContext' +import UnitInput from './UnitInput' + +/** + * Controls the unit for the specified dimension in the current unit context. + */ +const UnitDimensionSelect = React.memo(({label, dimension, onChange, disabled}) => { + const {units, setUnits} = useUnitContext() + const [error, setError] = useState() + const unit = units?.[dimension] + const disabledFinal = disabled || unit?.locked + const labelFinal = label || dimension + const oldValue = useRef(unit?.definition) + const [inputValue, setInputValue] = useState(unit?.definition) + + // React to changes in units + useEffect(() => { + setError(undefined) + setInputValue(unit?.definition) + }, [unit, dimension]) + + const handleAccept = useCallback((unit, unitString) => { + setUnits(old => { + const newUnits = { + ...old, + [dimension]: {...old[dimension], definition: unitString} + } + return newUnits + }) + onChange?.(unit) + oldValue.current = unitString + }, [dimension, onChange, setUnits]) + + const handleSelect = useCallback((unit, unitString) => { + handleAccept(unit, unit.label()) + }, [handleAccept]) + + const handleBlur = useCallback((onAccept) => { + onAccept(inputValue) + }, [inputValue]) + + const handleChange = useCallback((value) => { + oldValue.current = value + setInputValue(value) + }, []) + + return (unit && dimension !== 'dimensionless') + ? <UnitInput + value={inputValue} + label={labelFinal} + onChange={handleChange} + onAccept={handleAccept} + onSelect={handleSelect} + onBlur={handleBlur} + onError={setError} + dimension={dimension} + error={error} + disabled={disabledFinal} + disableGroup + /> + : null +}) +UnitDimensionSelect.propTypes = { + value: PropTypes.string, + label: PropTypes.string, + dimension: PropTypes.string, + onChange: PropTypes.func, + disabled: PropTypes.bool +} + +export default UnitDimensionSelect diff --git a/gui/src/components/units/UnitInput.js b/gui/src/components/units/UnitInput.js new file mode 100644 index 0000000000000000000000000000000000000000..d8cc5a41da7e9216df39d33731959fd8006ee53c --- /dev/null +++ b/gui/src/components/units/UnitInput.js @@ -0,0 +1,239 @@ +/* + * 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 React, {useCallback, useEffect, useMemo, useRef, useContext, createContext} from 'react' +import PropTypes from 'prop-types' +import {getSuggestions} from '../../utils' +import {unitMap} from './UnitContext' +import {parseQuantity} from './Quantity' +import {List, ListItemText, ListSubheader, makeStyles} from '@material-ui/core' +import {VariableSizeList} from 'react-window' +import {InputText} from '../search/input/InputText' + +/** + * Wrapper around InputText that is specialized in showing unit options. + */ +export const useInputStyles = makeStyles(theme => ({ + optionText: { + flexGrow: 1, + overflowX: 'scroll', + '&::-webkit-scrollbar': { + display: 'none' + }, + '-ms-overflow-style': 'none', + scrollbarWidth: 'none' + }, + noWrap: { + whiteSpace: 'nowrap' + }, + option: { + width: '100%', + display: 'flex', + alignItems: 'stretch' + } +})) +export const UnitInput = React.memo(({value, error, onChange, onAccept, onSelect, onError, onBlur, dimension, options, disabled, label, disableGroup}) => { + const styles = useInputStyles() + + // Predefine all option objects, all option paths and also pre-tokenize the + // options for faster matching. + const {keys, filter, finalOptions} = useMemo(() => { + const finalOptions = {} + Object.entries(unitMap) + .filter(([key, unit]) => unit.dimension === dimension) + .forEach(([key, unit]) => { + finalOptions[key] = { + key: key, + primary: `${unit.label} (${unit.abbreviation})`, + secondary: unit.aliases?.splice(1).join(', '), + dimension: unit.dimension, + unit: unit + } + }) + const keys = Object.keys(finalOptions) + const {filter} = getSuggestions(keys, 0) + return {keys, filter, finalOptions} + }, [dimension]) + + const handleChange = useCallback((value) => { + onChange?.(value) + }, [onChange]) + + const handleAccept = useCallback((key) => { + const {unit, unitString, error} = parseQuantity(key, false, true, dimension) + if (error) { + onError(error) + } else { + onAccept?.(unit, unitString) + } + }, [onAccept, onError, dimension]) + + const handleError = useCallback((value) => { + onError?.(value) + }, [onError]) + + const handleSelect = useCallback((key) => { + const {unit, unitString, error} = parseQuantity(key, false, true, dimension) + if (error) { + onError(error) + } else { + onSelect?.(unit, unitString) + } + }, [onSelect, onError, dimension]) + + // Used to filter the shown options based on input + const filterOptions = useCallback((opt, { inputValue }) => { + let filtered = filter(inputValue).map(option => option.value) + if (!disableGroup) filtered = filtered.sort((a, b) => options[a].group > options[b].group ? 1 : -1) + return filtered + }, [disableGroup, filter, options]) + + return <InputText + value={value} + TextFieldProps={{label, disabled}} + error={error} + onChange={handleChange} + onSelect={handleSelect} + onAccept={handleAccept} + onError={handleError} + onBlur={() => { onBlur(handleAccept) }} + disableClearable + disableAcceptOnBlur + suggestAllOnFocus + showOpenSuggestions + suggestions={keys} + ListboxComponent={ListboxUnit} + groupBy={disableGroup ? undefined : (key) => finalOptions?.[key]?.dimension} + renderGroup={disableGroup ? undefined : renderGroup} + getOptionLabel={option => option} + filterOptions={filterOptions} + renderOption={(key) => { + const option = finalOptions[key] + return <div className={styles.option}> + <ListItemText + primary={option.primary || option.key} + secondary={option.secondary} + className={styles.optionText} + primaryTypographyProps={{className: styles.noWrap}} + secondaryTypographyProps={{className: styles.noWrap}} + /> + </div> + }} + /> +}) +UnitInput.propTypes = { + value: PropTypes.string, + error: PropTypes.string, + label: PropTypes.string, + dimension: PropTypes.string, + options: PropTypes.object, + onChange: PropTypes.func, + onAccept: PropTypes.func, + onSelect: PropTypes.func, + onBlur: PropTypes.func, + onError: PropTypes.func, + disabled: PropTypes.bool, + disableGroup: PropTypes.bool +} + +export default UnitInput + +/** + * Custom virtualized list component for displaying unit values. + */ +const ListboxUnit = React.forwardRef((props, ref) => { + const { children, ...other } = props + const itemSize = 64 + const headerSize = 40 + const itemData = React.Children.toArray(children) + const itemCount = itemData.length + + // Calculate size of child element. + const getChildSize = (child) => { + return React.isValidElement(child) && child.type === ListSubheader + ? headerSize + : itemSize + } + + // Calculates the height of the suggestion box + const getHeight = () => { + return itemCount > 8 + ? 8 * itemSize + : itemData.map(getChildSize).reduce((a, b) => a + b, 0) + } + + const gridRef = useResetCache(itemCount) + + return <div ref={ref}> + <OuterElementContext.Provider value={other}> + <List disablePadding> + <VariableSizeList + itemData={itemData} + height={getHeight() + 2 * LISTBOX_PADDING} + width="100%" + ref={gridRef} + outerElementType={OuterElementType} + innerElementType="ul" + itemSize={(index) => getChildSize(itemData[index])} + overscanCount={5} + itemCount={itemCount} + > + {renderRow} + </VariableSizeList> + </List> + </OuterElementContext.Provider> + </div> +}) + +ListboxUnit.propTypes = { + children: PropTypes.node +} + +const LISTBOX_PADDING = 8 +const OuterElementContext = createContext({}) + +const OuterElementType = React.forwardRef((props, ref) => { + const outerProps = useContext(OuterElementContext) + return <div ref={ref} {...props} {...outerProps} /> +}) + +const renderGroup = (params) => [ + <ListSubheader key={params.key} component="div"> + {params.group} + </ListSubheader>, + params.children +] + +function useResetCache(data) { + const ref = useRef(null) + useEffect(() => { + if (ref.current != null) { + ref.current.resetAfterIndex(0, true) + } + }, [data]) + return ref +} + +function renderRow({ data, index, style }) { + return React.cloneElement(data[index], { + style: { + ...style, + top: style.top + LISTBOX_PADDING + } + }) +} diff --git a/gui/src/components/units/UnitMenu.js b/gui/src/components/units/UnitMenu.js new file mode 100644 index 0000000000000000000000000000000000000000..ac46985c7f86f1996b1cca9c0e1572146518aef1 --- /dev/null +++ b/gui/src/components/units/UnitMenu.js @@ -0,0 +1,184 @@ +/* + * 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 React, { useCallback, useState, useMemo } from 'react' +import { Box, Button, Menu, FormLabel, makeStyles, Typography } from '@material-ui/core' +import SettingsIcon from '@material-ui/icons/Settings' +import ReplayIcon from '@material-ui/icons/Replay' +import PropTypes from 'prop-types' +import { HelpButton } from '../Help' +import { InputText } from '../search/input/InputText' +import UnitDimensionSelect from './UnitDimensionSelect' +import UnitSystemSelect from './UnitSystemSelect' +import { useUnitContext } from './UnitContext' +import { Action, ActionHeader, Actions } from '../Actions' + +/** + * Menu for controlling all units in the current unit context. + */ +const useStyles = makeStyles(theme => ({ + // MUI will automatically add a padding whe scroll bar is visible. This is + // disabled here because the contents already have a sufficient margin. + list: { + paddingRight: '0 !important', + width: '100% !important' + } +})) +const UnitMenu = React.memo(({ + className, + onUnitChange, + onSystemChange +}) => { + const {units, dimensionMap, reset} = useUnitContext() + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) + const styles = useStyles() + + const dimensionOptions = useMemo(() => { + return Object.keys(units) + .filter((name) => name !== 'dimensionless') + .sort() + }, [units]) + const [dimension, setDimension] = useState(dimensionOptions[0]) + const [dimensionInput, setDimensionInput] = useState(dimensionMap[dimensionOptions[0]].label) + + const openMenu = useCallback((event) => { + setAnchorEl(event.currentTarget) + }, []) + const closeMenu = useCallback(() => { + setAnchorEl(null) + }, []) + + const handleChange = useCallback((value) => { + setDimensionInput(value) + }, []) + + const handleBlur = useCallback(() => { + const cleanValue = dimensionInput.trim().toLowerCase() + const dim = dimensionMap[cleanValue] + if (dim) { + setDimensionInput(dim.label) + setDimension(cleanValue) + } else { + setDimensionInput(dimensionMap[dimension].label) + } + }, [dimension, dimensionInput, dimensionMap]) + + const handleSelect = useCallback((value) => { + setDimension(value) + setDimensionInput(dimensionMap[value].label) + }, [dimensionMap]) + + return <> + <Button + aria-controls="customized-menu" + aria-haspopup="true" + variant="text" + color="primary" + onClick={openMenu} + className={className} + startIcon={<SettingsIcon/>} + > + Units + </Button> + <Menu + variant="menu" + anchorEl={anchorEl} + getContentAnchorEl={null} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + keepMounted + open={open} + onClose={closeMenu} + classes={{list: styles.list}} + > + <Box px={2.5} py={1} width="20rem"> + <Actions> + <ActionHeader> + <Typography variant='h6' fontSize='0.9rem'> + Unit Settings + </Typography> + </ActionHeader> + <Action + tooltip="About units" + ButtonComponent={HelpButton} + ButtonProps={{ + title: "About units", + IconProps: {fontSize: 'small'}, + content: ` + With these settings you can change in which units numerical data + is **displayed** in the browser. This display unit can be + different from the unit that is used when the data is stored. + Note that it is possible to define a fixed display unit in the + metainfo which will overrule the settings made through this + menu. + + Each NOMAD installation comes with a default set of unit + systems, which can be modified in the \`nomad.yaml\` + configuration file. You can here choose which of these unit + systems to use. + + Each unit system contains information about the exact unit that + should be used for each dimension. Many of the commonly used + units are available for selection, and you may use the SI + prefixes on any of them. Note that in some unit systems certain + dimensions are locked, which means that you cannot change them. + ` + }} + > + </Action> + <Action tooltip="Reset unit settings" onClick={reset}> + <ReplayIcon fontSize="small"/> + </Action> + </Actions> + <Box mt={1} /> + <FormLabel component="legend">Select unit system</FormLabel> + <UnitSystemSelect onChange={onSystemChange}/> + <Box mt={1} /> + <FormLabel component="legend">Select dimension and unit</FormLabel> + <Box mt={1} /> + <InputText + value={dimensionInput} + suggestions={dimensionOptions} + disableClearable + suggestAllOnFocus + showOpenSuggestions + onChange={handleChange} + onSelect={handleSelect} + onBlur={handleBlur} + renderOption={(option) => dimensionMap[option].label} + getOptionLabel={(option) => option} + TextFieldProps={{label: 'Dimension'}} + /> + <Box mt={1} /> + <UnitDimensionSelect + onChange={onUnitChange} + dimension={dimension} + label="Unit" + /> + </Box> + </Menu> + </> +}) + +UnitMenu.propTypes = { + onUnitChange: PropTypes.func, + onSystemChange: PropTypes.func, + className: PropTypes.string +} + +export default UnitMenu diff --git a/gui/src/components/UnitSelector.spec.js b/gui/src/components/units/UnitMenu.spec.js similarity index 62% rename from gui/src/components/UnitSelector.spec.js rename to gui/src/components/units/UnitMenu.spec.js index e80f66b7409f7e7c2edbee2da0c8ff03f67674d0..953e977896106a7eb1eac4e480ac0767e42c3ee9 100644 --- a/gui/src/components/UnitSelector.spec.js +++ b/gui/src/components/units/UnitMenu.spec.js @@ -17,15 +17,24 @@ */ import React from 'react' -import { renderNoAPI, screen } from './conftest.spec' +import { renderNoAPI, screen } from '../conftest.spec' import userEvent from '@testing-library/user-event' -import UnitSelector from './UnitSelector' +import UnitMenu from './UnitMenu' -test('initial unit selection is read correctly from config', async () => { +test('initial state is read correctly from config', async () => { + renderNoAPI(<UnitMenu />) + + // Correct unit system is selected const selection = window.nomadEnv.ui.unit_systems.selected - renderNoAPI(<UnitSelector />) const button = screen.getByButtonText("Units") await userEvent.click(button) - const optionSI = screen.getByLabelText(selection) - expect(optionSI).toBeChecked() + const optionSelected = screen.getByLabelText(selection) + expect(optionSelected).toBeChecked() + + // Dimension is shown + screen.getByDisplayValue('Activity') + + // Unit is shown + const selectedUnit = window.nomadEnv.ui.unit_systems.options[selection].units.activity.definition + screen.getByDisplayValue(selectedUnit) }) diff --git a/gui/src/components/units/UnitSystemSelect.js b/gui/src/components/units/UnitSystemSelect.js new file mode 100644 index 0000000000000000000000000000000000000000..b495880c91ac68e34cf1549903231a3b5167fe04 --- /dev/null +++ b/gui/src/components/units/UnitSystemSelect.js @@ -0,0 +1,45 @@ +/* + * 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 React, { useCallback } from 'react' +import {FormControlLabel, RadioGroup, Radio} from '@material-ui/core' +import PropTypes from 'prop-types' +import {useUnitContext} from './UnitContext' + +/** + * Controls the unit system in the current unit context. + */ +const UnitSystemSelect = React.memo(({onChange}) => { + const {unitSystems, selected, setSelected} = useUnitContext() + + const handleSystemChange = useCallback((event) => { + setSelected(event.target.value) + onChange && onChange(event) + }, [onChange, setSelected]) + + return <RadioGroup value={selected} onChange={handleSystemChange}> + {Object.entries(unitSystems).map(([key, system]) => + <FormControlLabel key={key} value={key} control={<Radio />} label={system.label} /> + )} + </RadioGroup> +}) +UnitSystemSelect.propTypes = { + onChange: PropTypes.func, + disabled: PropTypes.bool +} + +export default UnitSystemSelect diff --git a/gui/src/components/uploads/UploadsPage.js b/gui/src/components/uploads/UploadsPage.js index 03a733816e5968bb3f6f374797d9f28dd5c5be70..7c834eab508203fb0d6d06efb51100f5307a4a9a 100644 --- a/gui/src/components/uploads/UploadsPage.js +++ b/gui/src/components/uploads/UploadsPage.js @@ -23,7 +23,7 @@ import { Box, Divider, makeStyles } from '@material-ui/core' import ClipboardIcon from '@material-ui/icons/Assignment' -import HelpDialog from '../Help' +import { HelpButton } from '../Help' import { CopyToClipboard } from 'react-copy-to-clipboard' import { guiBase, servicesUploadLimit } from '../../config' import NewUploadButton from './NewUploadButton' @@ -167,42 +167,45 @@ function UploadCommands({uploadCommands}) { <ClipboardIcon /> </IconButton> </Tooltip> - {/* <button>Copy to clipboard with button</button> */} </CopyToClipboard> - <HelpDialog icon={<DetailsIcon/>} maxWidth="md" title="Alternative shell commands" content={` - As an experienced shell and *curl* user, you can modify the commands to - your liking. - - The given command can be modified. To see progress on large files, use - \`\`\` - ${uploadCommands.upload_progress_command} - \`\`\` - To \`tar\` and upload multiple folders in one command, use - \`\`\` - ${uploadCommands.upload_tar_command} - \`\`\` - - ### Form data vs. streaming - NOMAD accepts stream data (\`-T <local_file>\`) (like in the - examples above) or multi-part form data (\`-X PUT -f file=@<local_file>\`): - \`\`\` - ${uploadCommands.upload_command_form} - \`\`\` - We generally recommend to use streaming, because form data can produce very - large HTTP request on large files. Form data has the advantage of carrying - more information (e.g. the file name) to our servers (see below). - - #### Upload names - With multi-part form data (\`-X PUT -f file=@<local_file>\`), your upload will - be named after the file by default. With stream data (\`-T <local_file>\`) - there will be no default name. To set a custom name, you can use the URL - parameter \`name\`: - \`\`\` - ${uploadCommands.upload_command_with_name} - \`\`\` - Make sure to user proper [URL encoding](https://www.w3schools.com/tags/ref_urlencode.asp) - and shell encoding, if your name contains spaces or other special characters. - `}/> + <Tooltip title="Alternative shell commands"> + <span> + <HelpButton maxWidth="md" title="Alternative shell commands" content={` + As an experienced shell and *curl* user, you can modify the commands to + your liking. + + The given command can be modified. To see progress on large files, use + \`\`\` + ${uploadCommands.upload_progress_command} + \`\`\` + To \`tar\` and upload multiple folders in one command, use + \`\`\` + ${uploadCommands.upload_tar_command} + \`\`\` + + ### Form data vs. streaming + NOMAD accepts stream data (\`-T <local_file>\`) (like in the + examples above) or multi-part form data (\`-X PUT -f file=@<local_file>\`): + \`\`\` + ${uploadCommands.upload_command_form} + \`\`\` + We generally recommend to use streaming, because form data can produce very + large HTTP request on large files. Form data has the advantage of carrying + more information (e.g. the file name) to our servers (see below). + + #### Upload names + With multi-part form data (\`-X PUT -f file=@<local_file>\`), your upload will + be named after the file by default. With stream data (\`-T <local_file>\`) + there will be no default name. To set a custom name, you can use the URL + parameter \`name\`: + \`\`\` + ${uploadCommands.upload_command_with_name} + \`\`\` + Make sure to user proper [URL encoding](https://www.w3schools.com/tags/ref_urlencode.asp) + and shell encoding, if your name contains spaces or other special characters. + `}/> + </span> + </Tooltip> </div> </div> } diff --git a/gui/src/components/visualization/BandGap.js b/gui/src/components/visualization/BandGap.js index 4521bd99230d38bf9b475b537d46d97fa4f22c41..a33fd4a4f18cdbd0b5bd64c4e4e5c8900bebf772 100644 --- a/gui/src/components/visualization/BandGap.js +++ b/gui/src/components/visualization/BandGap.js @@ -18,7 +18,7 @@ import React, { } from 'react' import PropTypes from 'prop-types' import { SectionTable } from '../Quantity' -import { useUnits } from '../../units' +import { useUnitContext } from '../units/UnitContext' import { withErrorHandler } from '../ErrorHandler' import NoData from './NoData' import Placeholder from './Placeholder' @@ -37,7 +37,7 @@ const columns = { * table. */ const BandGap = React.memo(({data, section, 'data-testid': testID}) => { - const units = useUnits() + const {units} = useUnitContext() const extendedColumns = {} if (data && data[0].label) { extendedColumns.label = {label: '', align: 'left'} diff --git a/gui/src/components/visualization/BandStructure.js b/gui/src/components/visualization/BandStructure.js index 108d9d8d6aff75e11be01a02b6338ac5cd0956f5..201d4b9e7b7401e4bb7653c3b75b0d4e15787377 100644 --- a/gui/src/components/visualization/BandStructure.js +++ b/gui/src/components/visualization/BandStructure.js @@ -21,7 +21,8 @@ import { isFinite } from 'lodash' import { useTheme } from '@material-ui/core/styles' import Plot from '../plotting/Plot' import { add, distance, mergeObjects } from '../../utils' -import { Quantity, Unit } from '../../units' +import { Quantity } from '../units/Quantity' +import { Unit } from '../units/Unit' import { withErrorHandler } from '../ErrorHandler' import { msgNormalizationWarning } from '../../config' import { getLineStyles } from '../plotting/common' diff --git a/gui/src/components/visualization/DOS.js b/gui/src/components/visualization/DOS.js index 4bc4fe8cd0b9878f26457eae422c4f835c73d4d5..968436037d46c063b94f2ba417b279f7587ff4db 100644 --- a/gui/src/components/visualization/DOS.js +++ b/gui/src/components/visualization/DOS.js @@ -21,7 +21,9 @@ import { useTheme } from '@material-ui/core/styles' import { MoreVert } from '@material-ui/icons' import Plot from '../plotting/Plot' import { add, mergeObjects, resolveInternalRef } from '../../utils' -import { Quantity, Unit, useUnits } from '../../units' +import { Quantity } from '../units/Quantity' +import { Unit } from '../units/Unit' +import { useUnitContext } from '../units/UnitContext' import { withErrorHandler } from '../ErrorHandler' import { Action } from '../Actions' import { msgNormalizationWarning } from '../../config' @@ -39,7 +41,7 @@ const DOS = React.memo(({ 'data-testid': testID, ...other }) => { - const units = useUnits() + const {units} = useUnitContext() // Merge custom layout with default layout const initialLayout = useMemo(() => { diff --git a/gui/src/components/visualization/ElectronicProperties.js b/gui/src/components/visualization/ElectronicProperties.js index ff25b62c465912a4a8750dfb6441b0f9e590410a..2874d06de86cddf3af2303a1112f94fdf255d7fd 100644 --- a/gui/src/components/visualization/ElectronicProperties.js +++ b/gui/src/components/visualization/ElectronicProperties.js @@ -18,7 +18,8 @@ import React, { useCallback, useMemo } from 'react' import { Subject } from 'rxjs' import PropTypes from 'prop-types' -import { Quantity, useUnits } from '../../units' +import { Quantity } from '../units/Quantity' +import { useUnitContext } from '../units/UnitContext' import DOS from './DOS' import BandStructure from './BandStructure' import BrillouinZone from './BrillouinZone' @@ -54,7 +55,7 @@ const ElectronicProperties = React.memo(({ // We resolve the DMFT methodology from results.method const dmftprovenance = index?.results?.method?.simulation?.dmft || [] - const units = useUnits() + const {units} = useUnitContext() const range = useMemo(() => new Quantity(electronicRange, 'electron_volt').toSystem(units).value(), [units]) const bsLayout = useMemo(() => ({yaxis: {autorange: false, range: range}}), [range]) const dosLayout = useMemo(() => ({yaxis: {autorange: false, range: range}}), [range]) diff --git a/gui/src/components/visualization/EnergyVolumeCurve.js b/gui/src/components/visualization/EnergyVolumeCurve.js index 66b677229248cfd4e499a50a3152caf9af5076df..85a602d66c97ac2ffbf49cbdee26e6f4fc30ef0f 100644 --- a/gui/src/components/visualization/EnergyVolumeCurve.js +++ b/gui/src/components/visualization/EnergyVolumeCurve.js @@ -20,7 +20,8 @@ import PropTypes from 'prop-types' import { useTheme } from '@material-ui/core/styles' import Plot from '../plotting/Plot' import { withErrorHandler } from '../ErrorHandler' -import { Quantity, Unit } from '../../units' +import { Quantity } from '../units/Quantity' +import { Unit } from '../units/Unit' import { getLineStyles } from '../plotting/common' /** diff --git a/gui/src/components/visualization/GeometryOptimization.js b/gui/src/components/visualization/GeometryOptimization.js index 9d36a596cbe20daf0cf40613671dd2291698e9da..5453c892dae0ec047713f07e7ea258cb093ac057 100644 --- a/gui/src/components/visualization/GeometryOptimization.js +++ b/gui/src/components/visualization/GeometryOptimization.js @@ -24,7 +24,9 @@ import Plot from '../plotting/Plot' import { QuantityTable, QuantityRow, QuantityCell } from '../Quantity' import { ErrorHandler, withErrorHandler } from '../ErrorHandler' import { diffTotal } from '../../utils' -import { Quantity, Unit, useUnits } from '../../units' +import { Quantity } from '../units/Quantity' +import { Unit } from '../units/Unit' +import { useUnitContext } from '../units/UnitContext' import { PropertyGrid, PropertyItem } from '../entry/properties/PropertyCard' const energyUnit = new Unit('joule') @@ -39,7 +41,7 @@ const GeometryOptimization = React.memo(({ 'data-testid': testID }) => { const [finalData, setFinalData] = useState(!energies ? energies : undefined) - const units = useUnits() + const {units} = useUnitContext() const theme = useTheme() // Side effect that runs when the data that is displayed should change. By diff --git a/gui/src/components/visualization/HeatCapacity.js b/gui/src/components/visualization/HeatCapacity.js index e32699c2915d36fdb330eee9282b1e014b8afce9..9f5fc43d8d7ad30b83cbc88cd89493b0cbc570ea 100644 --- a/gui/src/components/visualization/HeatCapacity.js +++ b/gui/src/components/visualization/HeatCapacity.js @@ -20,7 +20,8 @@ import PropTypes from 'prop-types' import { useTheme } from '@material-ui/core/styles' import Plot from '../plotting/Plot' import { mergeObjects } from '../../utils' -import { Quantity, Unit } from '../../units' +import { Quantity } from '../units/Quantity' +import { Unit } from '../units/Unit' import { withErrorHandler } from '../ErrorHandler' const HeatCapacity = React.memo(({ diff --git a/gui/src/components/visualization/HelmholtzFreeEnergy.js b/gui/src/components/visualization/HelmholtzFreeEnergy.js index 1280affa69a94e74fd0c7189511efe5cdbf2a4a1..ef0c61fbedc09fd6405ede06a585f9d89b1c31f7 100644 --- a/gui/src/components/visualization/HelmholtzFreeEnergy.js +++ b/gui/src/components/visualization/HelmholtzFreeEnergy.js @@ -20,7 +20,8 @@ import PropTypes from 'prop-types' import { useTheme } from '@material-ui/core/styles' import Plot from '../plotting/Plot' import { mergeObjects } from '../../utils' -import { Quantity, Unit } from '../../units' +import { Quantity } from '../units/Quantity' +import { Unit } from '../units/Unit' import { withErrorHandler } from '../ErrorHandler' const HelmholtzFreeEnergy = React.memo(({ diff --git a/gui/src/components/visualization/MeanSquaredDisplacement.js b/gui/src/components/visualization/MeanSquaredDisplacement.js index c18c11b233b2a02bab93d8bb7b4355f166e21020..bff56f00f348b4d6cd5308d72fa11acdb90eed27 100644 --- a/gui/src/components/visualization/MeanSquaredDisplacement.js +++ b/gui/src/components/visualization/MeanSquaredDisplacement.js @@ -23,7 +23,9 @@ import { useTheme } from '@material-ui/core/styles' import Plot from '../plotting/Plot' import { PropertyGrid, PropertyItem } from '../entry/properties/PropertyCard' import { getLocation, formatNumber, DType } from '../../utils' -import { Quantity, Unit, useUnits } from '../../units' +import { Quantity } from '../units/Quantity' +import { Unit } from '../units/Unit' +import { useUnitContext } from '../units/UnitContext' import { ErrorHandler, withErrorHandler } from '../ErrorHandler' import { getLineStyles } from '../plotting/common' @@ -46,7 +48,7 @@ const MeanSquaredDisplacement = React.memo(({ const theme = useTheme() const [finalData, setFinalData] = useState(msd) const [finalLayout, setFinalLayout] = useState() - const units = useUnits() + const {units} = useUnitContext() // Check that the data is valid. Otherwise raise an exception. assert(isPlainObject(msd), 'Invalid msd data provided.') diff --git a/gui/src/components/visualization/RadialDistributionFunction.js b/gui/src/components/visualization/RadialDistributionFunction.js index d252bd9f1df9444881e11dc5449f749a548dce35..5df429dd38ddbe1a125953c1f650b5a0d6909ccb 100644 --- a/gui/src/components/visualization/RadialDistributionFunction.js +++ b/gui/src/components/visualization/RadialDistributionFunction.js @@ -23,7 +23,9 @@ import { useTheme } from '@material-ui/core/styles' import Plot from '../plotting/Plot' import { PropertyItem, PropertySubGrid } from '../entry/properties/PropertyCard' import { getLocation } from '../../utils' -import { Quantity, Unit, useUnits } from '../../units' +import { Quantity } from '../units/Quantity' +import { Unit } from '../units/Unit' +import { useUnitContext } from '../units/UnitContext' import { ErrorHandler, withErrorHandler } from '../ErrorHandler' import { getLineStyles } from '../plotting/common' @@ -44,7 +46,7 @@ const RadialDistributionFunction = React.memo(({ const theme = useTheme() const [finalData, setFinalData] = useState(rdf) const [finalLayout, setFinalLayout] = useState() - const units = useUnits() + const {units} = useUnitContext() // Check that the data is valid. Otherwise raise an exception. assert(isPlainObject(rdf), 'Invalid rdf data provided.') diff --git a/gui/src/components/visualization/RadiusOfGyration.js b/gui/src/components/visualization/RadiusOfGyration.js index 9e7bbe570ad851e25bdba8e792ddc48073e08e1b..094d7786bf4c9f0628322d1611696078649bd685 100644 --- a/gui/src/components/visualization/RadiusOfGyration.js +++ b/gui/src/components/visualization/RadiusOfGyration.js @@ -23,7 +23,9 @@ import { useTheme } from '@material-ui/core/styles' import Plot from '../plotting/Plot' import { PropertyItem, PropertySubGrid } from '../entry/properties/PropertyCard' import { getLocation } from '../../utils' -import { Quantity, Unit, useUnits } from '../../units' +import { Quantity } from '../units/Quantity' +import { Unit } from '../units/Unit' +import { useUnitContext } from '../units/UnitContext' import { ErrorHandler, withErrorHandler } from '../ErrorHandler' import { getLineStyles } from '../plotting/common' @@ -45,7 +47,7 @@ const RadiusOfGyration = React.memo(({ const theme = useTheme() const [finalData, setFinalData] = useState(rg) const [finalLayout, setFinalLayout] = useState() - const units = useUnits() + const {units} = useUnitContext() // Check that the data is valid. Otherwise raise an exception. assert(isPlainObject(rg), 'Invalid rg data provided.') diff --git a/gui/src/components/visualization/Spectra.js b/gui/src/components/visualization/Spectra.js index 3d5cc20333369e114238e24384df552957c649f2..68674dfb65819aa2c052650d9835e793b377ec28 100644 --- a/gui/src/components/visualization/Spectra.js +++ b/gui/src/components/visualization/Spectra.js @@ -23,7 +23,9 @@ import { MoreVert } from '@material-ui/icons' import Plot from '../plotting/Plot' import { mergeObjects } from '../../utils' import { getLineStyles } from '../plotting/common' -import { Quantity, Unit, useUnits } from '../../units' +import { Quantity } from '../units/Quantity' +import { Unit } from '../units/Unit' +import { useUnitContext } from '../units/UnitContext' import { withErrorHandler } from '../ErrorHandler' import { Action } from '../Actions' @@ -42,7 +44,7 @@ const Spectra = React.memo(({ }) => { const [finalData, setFinalData] = useState(!data ? data : undefined) const theme = useTheme() - const units = useUnits() + const {units} = useUnitContext() const [anchorEl, setAnchorEl] = React.useState(null) const [spectraNormalize, setSpectraNormalize] = useState(true) diff --git a/gui/src/components/visualization/Structure.js b/gui/src/components/visualization/Structure.js index 036435c40575ade612b96d5b553241bc826d08b1..3fda7e6b6e64dc24d68028dcccfabbef449071b8 100644 --- a/gui/src/components/visualization/Structure.js +++ b/gui/src/components/visualization/Structure.js @@ -44,7 +44,7 @@ import { Actions, Action } from '../Actions' import { withErrorHandler, withWebGLErrorHandler } from '../ErrorHandler' import { useHistory } from 'react-router-dom' import { isEmpty, flattenDeep } from 'lodash' -import { Quantity } from '../../units' +import { Quantity } from '../units/Quantity' import { delay } from '../../utils' import { useAsyncError } from '../../hooks' import clsx from 'clsx' diff --git a/gui/src/components/visualization/StructureBase.js b/gui/src/components/visualization/StructureBase.js index ad94f32f901aebb03aef5ec1d7b2f32d6c80eae5..3f240e49a42b72d45634c83f41082fc27141d42d 100644 --- a/gui/src/components/visualization/StructureBase.js +++ b/gui/src/components/visualization/StructureBase.js @@ -25,13 +25,8 @@ import { Button, Menu, MenuItem, - Tooltip, Typography, - FormControl, - FormLabel, - FormControlLabel, - Radio, - RadioGroup + FormControlLabel } from '@material-ui/core' import { Alert } from '@material-ui/lab' import { @@ -43,20 +38,15 @@ import { ViewList, GetApp } from '@material-ui/icons' -import { DownloadSystemMenu } from '../buttons/DownloadSystemButton' +import { DownloadSystemMenu, WrapModeRadio } from '../buttons/DownloadSystemButton' import Floatable from './Floatable' import NoData from './NoData' import Placeholder from './Placeholder' import { Actions, Action } from '../Actions' import { withErrorHandler, withWebGLErrorHandler } from '../ErrorHandler' import { isNil } from 'lodash' -import { Quantity } from '../../units' +import { Quantity } from '../units/Quantity' -export const WrapMode = { - Original: "original", - Wrap: "wrap", - Unwrap: "unwrap" -} /** * Used to control a 3D system visualization that is implemented in the * 'children' prop. This allows for an easier change of visualization @@ -315,29 +305,7 @@ const StructureBase = React.memo(({ label='Show simulation cell' /> </MenuItem> - <FormControl key='wrap' component="fieldset" className={styles.menuItem}> - <FormLabel component="legend">Wrap mode</FormLabel> - <RadioGroup - value={wrapMode} - onChange={handleWrapModeChange} - > - {Object.entries(WrapMode).map(([key, value]) => - <FormControlLabel - key={key} - value={value} - control={<Radio color="primary" disabled={disableWrapMode}/>} - label={<Tooltip - title={{ - [WrapMode.Original]: 'Original positions', - [WrapMode.Wrap]: 'Positions wrapped inside the cell respecting periodic boundary conditions', - [WrapMode.Unwrap]: 'Reconstructs positions so that small structures are not split by periodic cell boundary.' - }[value]}> - <span>{key}</span> - </Tooltip>} - /> - )} - </RadioGroup> - </FormControl> + <WrapModeRadio value={wrapMode} onChange={handleWrapModeChange} disabled={disableWrapMode} className={styles.menuItem}/> </Menu> </div> </div> diff --git a/gui/src/components/visualization/StructureNGL.js b/gui/src/components/visualization/StructureNGL.js index fd17281d30a2a8c14e6aa0705aae1c29007a390e..2c70a7f19229d72dabdf318213bf164faab35e65 100644 --- a/gui/src/components/visualization/StructureNGL.js +++ b/gui/src/components/visualization/StructureNGL.js @@ -1,4 +1,3 @@ -/* eslint-disable no-unused-vars */ /* * Copyright The NOMAD Authors. * @@ -29,7 +28,8 @@ import { makeStyles } from '@material-ui/core' import PropTypes from 'prop-types' import { isNil, isEqual, range } from 'lodash' import { Stage, Vector3 } from 'ngl' -import StructureBase, { WrapMode } from './StructureBase' +import StructureBase from './StructureBase' +import { wrapModes } from '../buttons/DownloadSystemButton' import * as THREE from 'three' import { withErrorHandler } from '../ErrorHandler' import { useAsyncError } from '../../hooks' @@ -67,7 +67,7 @@ const StructureNGL = React.memo(({ const [showCell, setShowCell] = useState(true) const [showLatticeConstants, setShowLatticeConstants] = useState(true) const [disableShowLatticeConstants, setDisableShowLatticeContants] = useState(true) - const [wrapMode, setWrapMode] = useState(WrapMode.Wrap) + const [wrapMode, setWrapMode] = useState(wrapModes.wrap.key) const [disableWrapMode, setDisableWrapMode] = useState(false) const [disableShowCell, setDisableShowCell] = useState(false) const [disableShowBonds, setDisableShowBonds] = useState(false) @@ -350,7 +350,7 @@ const StructureNGL = React.memo(({ atoms: atomRepr, sele: sele, indices: indices, - wrapMode: (isMonomer || isMolecule) ? WrapMode.Unwrap : WrapMode.Wrap + wrapMode: (isMonomer || isMolecule) ? wrapModes.unwrap.key : wrapModes.wrap.key } for (const child of top.child_systems || []) { if (!child.atoms) addRepresentation(child) @@ -1220,7 +1220,7 @@ function wrapRepresentation(component, representation) { } // Use wrapped positions - if (wrapMode === WrapMode.Wrap) { + if (wrapMode === wrapModes.wrap.key) { if (!isNil(representation.posWrap)) { posNew = representation.posWrap } else { @@ -1232,7 +1232,7 @@ function wrapRepresentation(component, representation) { representation.posWrap = posNew } // Use unwrapped positions - } else if (wrapMode === WrapMode.Unwrap) { + } else if (wrapMode === wrapModes.unwrap.key) { if (!isNil(representation.posUnwrap)) { posNew = representation.posUnwrap } else { @@ -1246,7 +1246,7 @@ function wrapRepresentation(component, representation) { representation.posUnwrap = posNew } // Use original positions - } else if (wrapMode === WrapMode.Original) { + } else if (wrapMode === wrapModes.original.key) { posNew = representation.posCart } else { throw Error('Invalid wrapmode provided.') diff --git a/gui/src/components/visualization/Trajectory.js b/gui/src/components/visualization/Trajectory.js index ac59f3f8141838028f37104cbf0b840428c478a6..803c27dbdf66998faff544b6b13c7136c4449465 100644 --- a/gui/src/components/visualization/Trajectory.js +++ b/gui/src/components/visualization/Trajectory.js @@ -26,7 +26,9 @@ import { PropertyProvenanceItem, PropertyProvenanceList } from '../entry/properties/PropertyCard' -import { Quantity, Unit, useUnits } from '../../units' +import { Quantity } from '../units/Quantity' +import { Unit } from '../units/Unit' +import { useUnitContext } from '../units/UnitContext' import { withErrorHandler } from '../ErrorHandler' import { getPlotLayoutVertical, getPlotTracesVertical } from '../plotting/common' @@ -60,7 +62,7 @@ const Trajectory = React.memo(({ if (energyPotential !== false) ++nPlots const styles = useStyles({classes: classes}) const theme = useTheme() - const units = useUnits() + const {units} = useUnitContext() const [finalData, setFinalData] = useState(nPlots === 0 ? false : undefined) const [finalLayout, setFinalLayout] = useState() diff --git a/gui/src/components/visualization/VibrationalProperties.js b/gui/src/components/visualization/VibrationalProperties.js index ec81bc0326376cc05973b4973cad9bcb7a33f663..001094a2d38c04d3a718a029a966a956b9031b51 100644 --- a/gui/src/components/visualization/VibrationalProperties.js +++ b/gui/src/components/visualization/VibrationalProperties.js @@ -23,7 +23,7 @@ import BandStructure from './BandStructure' import HeatCapacity from './HeatCapacity' import { PropertyGrid, PropertyItem } from '../entry/properties/PropertyCard' import HelmholtzFreeEnergy from './HelmholtzFreeEnergy' -import { Quantity } from '../../units' +import { Quantity } from '../units/Quantity' const VibrationalProperties = React.memo(({ bs, diff --git a/gui/src/units.js b/gui/src/units.js deleted file mode 100644 index 2ee2228e7d981abb8b01b3f7d2213670a5d58523..0000000000000000000000000000000000000000 --- a/gui/src/units.js +++ /dev/null @@ -1,671 +0,0 @@ -/* - * 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 { isNil, startCase, toLower, has, cloneDeep, isString, isNumber, isArray } from 'lodash' -import { mapDeep } from './utils' -import { Unit as UnitMathJS, createUnit } from 'mathjs' -import { atom, useRecoilValue } from 'recoil' -import { ui, unitList, unitPrefixes as prefixes } from './config' - -// Delete all units and prefixes that come by default with Math.js. This way -// they cannot be intermixed with the NOMAD units. Notice that we have to clear -// them in place: they are defined as const. -Object.keys(UnitMathJS.UNITS).forEach(name => UnitMathJS.deleteUnit(name)) -UnitMathJS.BASE_DIMENSIONS.splice(0, UnitMathJS.BASE_DIMENSIONS.length) -Object.getOwnPropertyNames(UnitMathJS.BASE_UNITS).forEach(function(prop) { - delete UnitMathJS.BASE_UNITS[prop] -}) -Object.getOwnPropertyNames(UnitMathJS.PREFIXES).forEach(function(prop) { - delete UnitMathJS.PREFIXES[prop] -}) -UnitMathJS.PREFIXES.NONE = {'': { name: '', value: 1, scientific: true }} -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(['_', 'Å', 'Å', 'å', '°', 'µ', 'ö', 'é', '∞']) - return specialChars.has(c) -} -const isGreekLetter = function(c) { - const charCode = c.charCodeAt(0) - return (charCode > 912 && charCode < 970) -} -UnitMathJS.isValidAlpha = function(c) { - return isAlphaOriginal(c) || isSpecialChar(c) || isGreekLetter(c) -} - -// Create MathJS unit definitions from the data exported by 'nomad dev units' -const unitToAbbreviationMap = {} -const unitDefinitions = {} -for (let def of unitList) { - const name = def.name - def = { - ...def, - baseName: def.dimension, - prefixes: 'pint' - } - unitDefinitions[name] = def - // Register abbreviations - if (def.abbreviation) { - unitToAbbreviationMap[name] = def.abbreviation - if (def.aliases) { - for (const alias of def.aliases) { - unitToAbbreviationMap[alias] = def.abbreviation - } - } - } -} -createUnit(unitDefinitions, {override: true}) - -export const unitMap = Object.fromEntries(unitList.map(x => [x.name, x])) - -// Define the unit systems -const SIUnits = { - // Base units - dimensionless: {name: 'dimensionless', fixed: false}, - length: {name: 'meter', fixed: false}, - mass: {name: 'kilogram', fixed: false}, - time: {name: 'second', fixed: false}, - current: {name: 'ampere', fixed: false}, - temperature: {name: 'kelvin', fixed: false}, - luminosity: {name: 'candela', fixed: false}, - luminous_flux: {name: 'lumen', fixed: false}, - substance: {name: 'mole', fixed: false}, - angle: {name: 'radian', fixed: false}, - information: {name: 'bit', fixed: false}, - // Derived units with specific name - force: {name: 'newton', fixed: false}, - energy: {name: 'joule', fixed: false}, - power: {name: 'watt', fixed: false}, - pressure: {name: 'pascal', fixed: false}, - charge: {name: 'coulomb', fixed: false}, - resistance: {name: 'ohm', fixed: false}, - conductance: {name: 'siemens', fixed: false}, - inductance: {name: 'henry', fixed: false}, - magnetic_flux: {name: 'weber', fixed: false}, - magnetic_field: {name: 'tesla', fixed: false}, - frequency: {name: 'hertz', fixed: false}, - luminance: {name: 'nit', fixed: false}, - illuminance: {name: 'lux', fixed: false}, - electric_potential: {name: 'volt', fixed: false}, - capacitance: {name: 'farad', fixed: false}, - activity: {name: 'katal', fixed: false} - // TODO: Derived units without a specific name. Cannot be registered as such - // as they don't have labels or separate definitions. - // area: {name: 'm^2', fixed: false}, - // volume: {name: 'm^2', fixed: false}, - // wavenumber: {name: '1 / m', fixed: false}, - // speed: {name: 'm/s', fixed: false}, - // acceleration: {name: 'm / s^2', fixed: false}, - // density: {name: 'kg / m^3', fixed: false}, - // viscosity: {name: 'Pa / s', fixed: false}, - // kinematic_viscosity: {name: 'm^2 / s', fixed: false}, - // fluidity: {name: '1 / Pa / s', fixed: false}, - // concentration: {name: 'mol / m^3', fixed: false}, - // entropy: {name: 'J / K', fixed: false}, - // molar_entropy: {name: 'J / K / mole', fixed: false}, - // electric_field: {name: 'V / m', fixed: false}, - // intensity: {name: 'W / m^2', fixed: false}, - // electric_dipole: {name: 'C m', fixed: false}, - // electric_quadrupole: {name: 'C m^2', fixed: false}, - // magnetic_dipole: {name: 'A m^2', fixed: false} -} -const SIUnitsFixed = cloneDeep(SIUnits) -Object.values(SIUnitsFixed).forEach(value => { value.fixed = true }) - -export const unitSystems = { - Custom: { - label: 'Custom', - description: 'Custom unit setup', - units: { - ...SIUnits, - length: {name: 'angstrom', fixed: false}, - time: {name: 'femtosecond', fixed: false}, - energy: {name: 'electron_volt', fixed: false}, - pressure: {name: 'gigapascal', fixed: false}, - angle: {name: 'degree', fixed: false} - } - }, - SI: { - label: 'SI', - description: 'International System of Units (SI)', - units: SIUnitsFixed - }, - AU: { - label: 'AU', - description: 'Hartree atomic units', - units: { - ...SIUnits, - time: {name: 'atomic_unit_of_time', fixed: true}, - length: {name: 'bohr', fixed: true}, - mass: {name: 'electron_mass', fixed: true}, - current: {name: 'atomic_unit_of_current', fixed: true}, - temperature: {name: 'atomic_unit_of_temperature', fixed: true}, - force: {name: 'atomic_unit_of_force', fixed: true}, - energy: {name: 'hartree', fixed: true}, - pressure: {name: 'atomic_unit_of_pressure', fixed: true}, - angle: {name: 'radian', fixed: true} - } - } -} - -// Create a map of all units per dimension -export const dimensionMap = {} -for (const def of unitList) { - const name = def.name - const dimension = def.dimension - if (isNil(dimension)) { - continue - } - const oldInfo = dimensionMap[dimension] || { - label: startCase(toLower(dimension.replace('_', ' '))) - } - const oldList = oldInfo.units || [] - oldList.push(name) - oldInfo.units = oldList - dimensionMap[dimension] = oldInfo -} - -// Check that all units in the unit systems have been registered -for (const [systemName, system] of Object.entries(unitSystems)) { - for (const [dimension, unit] of Object.entries(system.units)) { - if (isNil(UnitMathJS.UNITS[unit.name])) { - throw Error(`Unknown unit for dimension '${dimension}' found in system '${systemName}'`) - } - } -} - -// A state containing the currently configured unit system. -export const unitsState = atom({ - key: 'units', - default: unitSystems[ui?.unit_systems?.selected || 'Custom'] || unitSystems.Custom -}) - -/** - * Convenience hook for using the currently set units. - * @returns Object containing the currently set units for each dimension (e.g. - * {energy: 'joule'}) - */ -export const useUnits = () => { - const unitSystem = useRecoilValue(unitsState) - return unitSystem.units -} - -/** - * Helper class for persisting unit information. - * - * Builds upon the math.js Unit class system, but adds additional functionality, - * including: - * - Ability to convert to any unit system given as an argument - * - Abbreviated labels for dense formatting - */ -export class Unit { - /** - * @param {str | Unit} unit Unit for the quantity. - */ - constructor(unit) { - if (isString(unit)) { - unit = this.normalizeExpression(unit) - unit = new UnitMathJS(undefined, unit) - } else if (unit instanceof Unit) { - unit = unit.mathjsUnit.clone() - } else if (unit instanceof UnitMathJS) { - unit = unit.clone() - } else { - throw Error('Please provide the unit as a string or as an instance of Unit.') - } - this.mathjsUnit = unit - // this._labelabbreviate = undefined - // this._label = undefined - } - - /** - * 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 - */ - normalizeExpression(expression) { - let normalized = expression.replace(/\*\*/g, '^') - normalized = normalized.replace(/delta_/g, '') - normalized = normalized.replace(/Δ/g, '') - return normalized - } - - /** - * Checks if the given unit has the same base dimensions as this one. - * @param {str | Unit} unit Unit to compare to - * @returns boolean Whether the units have the same base dimensions. - */ - equalBase(unit) { - if (isString(unit)) { - unit = this.normalizeExpression(unit) - unit = new Unit(unit) - } - return this.mathjsUnit.equalBase(unit.mathjsUnit) - } - - /** - * Used to create a human-readable description of the unit as a string. - * - * @param {bool} abbreviate Whether to abbreviate the label using the - * abbreviations for each unit and prefix. If false, the original unit names - * (as given or defined by the unit system) are used. - * @returns A string representing the unit. - */ - label(abbreviate = true) { - // 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 getName(unit) { - if (unit.base.key === 'dimensionless') { - return '' - } - if (!abbreviate) { - return unit.name - } - return unitToAbbreviationMap?.[unit.name] || unit.name - } - - function getPrefix(unit, original) { - if (!abbreviate) { - return original - } - const prefixMap = { - // SI - deca: 'da', - hecto: 'h', - kilo: 'k', - mega: 'M', - giga: 'G', - tera: 'T', - peta: 'P', - exa: 'E', - zetta: 'Z', - yotta: 'Y', - deci: 'd', - centi: 'c', - milli: 'm', - micro: 'u', - nano: 'n', - pico: 'p', - femto: 'f', - atto: 'a', - zepto: 'z', - yocto: 'y', - // IEC - kibi: 'Ki', - mebi: 'Mi', - gibi: 'Gi', - tebi: 'Ti', - pebi: 'Pi', - exi: 'Ei', - zebi: 'Zi', - yobi: 'Yi' - } - return prefixMap?.[original] || original - } - - for (let i = 0; i < units.length; i++) { - if (units[i].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 - } - } else if (units[i].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) - if (nNum > 0) { - strDen += ` ${prefix}${name}` - if (Math.abs(units[i].power + 1.0) > 1e-15) { - strDen += '^' + (-units[i].power) - } - } else { - strDen += ` ${prefix}${name}` - strDen += '^' + (units[i].power) - } - } - } - } - // Remove leading whitespace - strNum = strNum.substr(1) - strDen = strDen.substr(1) - - // Add parentheses for better copy/paste back into evaluate, for example, or - // for better pretty print formatting - if (nNum > 1 && nDen > 0) { - strNum = '(' + strNum + ')' - } - if (nDen > 1 && nNum > 0) { - strDen = '(' + strDen + ')' - } - - let str = strNum - if (nNum > 0 && nDen > 0) { - str += ' / ' - } - str += strDen - - // this._labelabbreviate = abbreviate - // this._label = str - return str - } - - /** - * Gets the dimension of this unit as a string. The order of the dimensions is - * fixed (determined at unit registration time). - * - * @param {boolean} base Whether to return dimension in base units. Otherwise - * the original unit dimensions are used. - * @returns The dimensionality as a string, e.g. 'time^2 energy mass^-2' - */ - dimension(base = true) { - const dimensions = Object.keys(UnitMathJS.BASE_UNITS) - const dimensionMap = Object.fromEntries(dimensions.map(name => [name, 0])) - - if (base) { - const BASE_DIMENSIONS = UnitMathJS.BASE_DIMENSIONS - for (let i = 0; i < BASE_DIMENSIONS.length; ++i) { - const power = this?.mathjsUnit.dimensions?.[i] - if (power) { - dimensionMap[BASE_DIMENSIONS[i]] += power - } - } - } else { - for (const unit of this?.mathjsUnit.units) { - const power = unit.power - if (power) { - dimensionMap[unit.unit.base.key] += power - } - } - } - return Object.entries(dimensionMap) - .filter(d => d[1] !== 0) - .map(d => `${d[0]}${((d[1] < 0 || d[1] > 1) && `^${d[1]}`) || ''}`).join(' ') - } - - /** - * Function for converting to another unit. - * - * @param {str | Unit} unit The target unit - * @returns A new Unit expressed in the given units. - */ - to(unit) { - if (isString(unit)) { - unit = this.normalizeExpression(unit) - } else if (unit instanceof Unit) { - unit = unit.label() - } else { - throw Error('Unknown unit type. Please provide the unit as as string or as instance of Unit.') - } - - // 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) - return new Unit(this.mathjsUnit.to(unit)) - } - - /** - * Function for converting the value of this Unit to the SI unit system. - * - * @returns A new Unit instance in the SI unit system. - */ - toSI() { - return this.toSystem(unitSystems.SI.units) - } - - /** - * Function for converting the value of this unit to another unit system. - * - * Notice that converting a unit to another unit system is not as easy as - * conversions to a specific unit. When converting to a specific unit one can - * simply check that the dimensions match and go ahead with the conversion. - * With unit systems, there can be multiple alternative forms, and choosing a - * good one is more difficult. E.g. should 'a_u_force * angstrom' be converted - * into: - * - * a) N m - * b) J - * c) (kg m^2) / s^2 - * - * By default this function will try to preserve the original unit dimensions - * and not convert everything down to base units. If a derived unit is not - * present, it will, however, attempt to convert it to the base units. Any - * further simplication is not performed. - * - * @param {object} system The target unit system. - * @returns A new Unit instance in the given system. - */ - toSystem(system) { - // Go through the currently defined units, identify their dimension and look - // for the corresponding dimension in the given unit system. If one is - // present, convert to it. Otherwise convert to base dimensions. - const UNITS = UnitMathJS.UNITS - const PREFIXES = UnitMathJS.PREFIXES - const BASE_DIMENSIONS = UnitMathJS.BASE_DIMENSIONS - const BASE_UNITS = UnitMathJS.BASE_UNITS - const proposedUnitList = [] - for (const unit of this.mathjsUnit.units) { - const dimension = unit.unit.base.key - const newUnitName = system?.[dimension]?.name - const newUnit = UNITS[newUnitName] - // If the unit for this dimension is defined, use it - if (!isNil(newUnitName)) { - proposedUnitList.push({ - unit: newUnit, - prefix: PREFIXES.NONE[''], - power: unit.power || 0 - }) - // Otherwise convert to base units - } else { - let missingBaseDim = false - const baseUnit = BASE_UNITS[dimension] - const newDimensions = baseUnit.dimensions - for (let i = 0; i < BASE_DIMENSIONS.length; i++) { - const baseDim = BASE_DIMENSIONS[i] - if (Math.abs(newDimensions[i] || 0) > 1e-12) { - if (has(system, baseDim)) { - proposedUnitList.push({ - unit: UNITS[system[baseDim].name], - prefix: PREFIXES.NONE[''], - power: unit.power ? newDimensions[i] * unit.power : 0 - }) - } else { - missingBaseDim = true - } - } - } - if (missingBaseDim) { - throw Error(`The given unit system does not contain the required unit definitions for converting ${unit.name} with dimension ${dimension}.`) - } - } - } - - const ret = this.mathjsUnit.clone() - ret.units = proposedUnitList - return new Unit(ret) - } -} - -/** - * Class for persisting persisting a numeric value together with unit - * information. - */ -export class Quantity { - /** - * @param {number | n-dimensional array of numbers} value Numeric value. See - * also the argument 'normalized'. - * @param {str | Unit} unit Unit for the quantity. - * @param {boolean} normalized Whether the given numeric value is already - * normalized to base units. - */ - constructor(value, unit, normalized = false) { - this.unit = new Unit(unit) - if (!isNumber(value) && !isArray(value)) { - throw Error('Please provide the the value 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. - this.normalized_value = normalized ? value : this.normalize(value) - } - - /** - * Get value in current units. - * @returns The numeric value in the currently set units. - */ - value() { - return this.denormalize(this.normalized_value) - } - - /** - * Convert value from currently set units to base units. - * @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)) - } - - /** - * Convert value from base units to currently set units. - * @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)) - } - - label() { - return this.unit.label() - } - - dimension(base) { - return this.unit.dimension(base) - } - - to(unit) { - return new Quantity(this.normalized_value, this.unit.to(unit), true) - } - - toSI() { - return new Quantity(this.normalized_value, this.unit.toSI(), true) - } - - toSystem(system) { - return new Quantity(this.normalized_value, this.unit.toSystem(system), true) - } - - /** - * Checks if the given Quantity is equal to this one. - * @param {Quantity} quantity Quantity to compare to - * @returns boolean Whether quantities are equal - */ - equal(quantity) { - if (quantity instanceof Quantity) { - return this.normalized_value === quantity.normalized_value && this.unit.equalBase(quantity.unit) - } else { - throw Error('The given value is not an instance of Quantity.') - } - } -} - -/** - * Convenience function for getting compatible units for a given dimension. - * Returns all compatible units that have been registered. - * - * @param {string} dimension The dimension. - * @returns Array of compatible units. - */ -export function getUnits(dimension) { - return dimensionMap?.[dimension]?.units || [] -} - -/** - * Convenience function for parsing unit information from a string. - * - * @param {boolean} requireValue Whether a value is required. - * @param {boolean} requireUnit Whether a unit is required. - * @param {string} dimension Dimension for the unit. Nil value means a - * dimensionless unit. - * @returns Object containing the following properties, if available: - * - value: Numerical value as a number - * - valueString: Numerical value as a string - * - unit: Unit instance - * - unitString: Unit as a string - * - error: Error messsage - */ -export function parseQuantity(input, requireValue = true, requireUnit = true, dimension = undefined) { - input = input.trim() - const valueString = input.match(/^[+-]?((\d+\.\d+|\d+\.|\.\d?|\d+)(e|e\+|e-)\d+|(\d+\.\d+|\d+\.|\.\d?|\d+))?/)?.[0] - if (requireValue && isNil(valueString)) { - return {error: 'Enter a valid numerical value'} - } - const value = Number(valueString) - const unitString = input.substring(valueString.length).trim() - const dim = isNil(dimension) ? 'dimensionless' : dimension - if (unitString === '' && dim !== 'dimensionless' && requireUnit) { - return {value, valueString, unitString, error: 'Enter a valid unit'} - } - if (unitString === '' && !requireUnit) { - return {value, valueString, unitString} - } - if (dim === 'dimensionless' && unitString !== '') { - return {value, valueString, unitString, error: 'Enter a numerical value without units'} - } - let unit - try { - unit = new Unit(dim === 'dimensionless' ? 'dimensionless' : input) - } catch { - return {valueString, value, unitString, error: `Unknown unit '${unitString}'`} - } - if (unit.dimension(false) !== dimension) { - return {valueString, value, unitString, unit, error: `Incompatible unit`} - } - return {value, valueString, unit, unitString} -} diff --git a/gui/src/utils.js b/gui/src/utils.js index b00185f69d338931442c443e6a278d6036080b97..b082f11a8aa109004e89d64bdca7bd510a64996c 100644 --- a/gui/src/utils.js +++ b/gui/src/utils.js @@ -17,7 +17,7 @@ */ import minimatch from 'minimatch' import { cloneDeep, merge, isSet, isNil, isArray, isString, isNumber, isPlainObject, startCase, isEmpty } from 'lodash' -import { Quantity } from './units' +import { Quantity } from './components/units/Quantity' import { format } from 'date-fns' import { dateFormat, guiBase, apiBase, searchQuantities, parserMetadata, schemaSeparator, dtypeSeparator, yamlSchemaPrefix } from './config' const crypto = require('crypto') @@ -605,10 +605,10 @@ export function getDeserializer(dtype, dimension) { throw Error(`Could not parse the number ${split[0]}`) } return split.length === 1 - ? new Quantity(value, units?.[dimension]?.name || 'dimensionless') + ? new Quantity(value, units?.[dimension]?.definition || 'dimensionless') : new Quantity(value, split[1]) } if (isNumber(value)) { - return new Quantity(value, units?.[dimension]?.name || 'dimensionless') + return new Quantity(value, units?.[dimension]?.definition || 'dimensionless') } return value } @@ -1382,9 +1382,9 @@ export const alphabet = [ * Function for creating suggestions functionality for a list of string values. * Mimics the suggestion logic used by the suggestions API endpoint. * - * @param {str} category Category for the suggestions * @param {array} values Array of available values * @param {number} minLength Minimum input length before suggestions are considered. + * @param {str} category Category for the suggestions * @param {func} text Function that maps the value into the suggested text input * * @return {object} Object containing a list of options and a function for diff --git a/gui/tests/artifacts.js b/gui/tests/artifacts.js index c9734af9aa755a022fdc8611bdd3a14eb50ff6bd..8e32f71876f325cb141df468d3840f280db09846 100644 --- a/gui/tests/artifacts.js +++ b/gui/tests/artifacts.js @@ -78436,14 +78436,6 @@ window.nomadArtifacts = { "definition": "96485.33212331001 ampere * second", "offset": 0.0 }, - { - "name": "femtosecond", - "dimension": "time", - "label": "Femtosecond", - "abbreviation": "fs", - "definition": "1e-15 second", - "offset": 0.0 - }, { "name": "fermi", "dimension": "length", @@ -78566,14 +78558,6 @@ window.nomadArtifacts = { "definition": "0.0001 kilogram / ampere / second ^ 2", "offset": 0.0 }, - { - "name": "gigapascal", - "dimension": "pressure", - "label": "Gigapascal", - "abbreviation": "GPa", - "definition": "1000000000.0 kilogram / meter / second ^ 2", - "offset": 0.0 - }, { "name": "grade", "dimension": "angle", @@ -78989,14 +78973,6 @@ window.nomadArtifacts = { "definition": "4.84813681109536e-09 radian", "offset": 0.0 }, - { - "name": "millibar", - "dimension": "pressure", - "label": "Millibar", - "abbreviation": "mbar", - "definition": "100.0 kilogram / meter / second ^ 2", - "offset": 0.0 - }, { "name": "millimeter_Hg", "dimension": "pressure", diff --git a/gui/tests/env.js b/gui/tests/env.js index bef29869bc344da70ea4c6dcf4ced65a6d3bcba0..b8819f67867beb497dd99b339daddd33e4184008 100644 --- a/gui/tests/env.js +++ b/gui/tests/env.js @@ -19,6 +19,347 @@ window.nomadEnv = { "title": "NOMAD" }, "unit_systems": { + "options": { + "Custom": { + "label": "Custom", + "units": { + "length": { + "definition": "\u00c5", + "locked": false + }, + "time": { + "definition": "fs", + "locked": false + }, + "energy": { + "definition": "eV", + "locked": false + }, + "pressure": { + "definition": "GPa", + "locked": false + }, + "angle": { + "definition": "\u00b0", + "locked": false + }, + "dimensionless": { + "definition": "dimensionless", + "locked": false + }, + "mass": { + "definition": "kg", + "locked": false + }, + "current": { + "definition": "A", + "locked": false + }, + "temperature": { + "definition": "K", + "locked": false + }, + "luminosity": { + "definition": "cd", + "locked": false + }, + "luminous_flux": { + "definition": "lm", + "locked": false + }, + "substance": { + "definition": "mol", + "locked": false + }, + "information": { + "definition": "bit", + "locked": false + }, + "force": { + "definition": "N", + "locked": false + }, + "power": { + "definition": "W", + "locked": false + }, + "charge": { + "definition": "C", + "locked": false + }, + "resistance": { + "definition": "\u03a9", + "locked": false + }, + "conductance": { + "definition": "S", + "locked": false + }, + "inductance": { + "definition": "H", + "locked": false + }, + "magnetic_flux": { + "definition": "Wb", + "locked": false + }, + "magnetic_field": { + "definition": "T", + "locked": false + }, + "frequency": { + "definition": "Hz", + "locked": false + }, + "luminance": { + "definition": "nit", + "locked": false + }, + "illuminance": { + "definition": "lx", + "locked": false + }, + "electric_potential": { + "definition": "V", + "locked": false + }, + "capacitance": { + "definition": "F", + "locked": false + }, + "activity": { + "definition": "kat", + "locked": false + } + } + }, + "SI": { + "label": "International System of Units (SI)", + "units": { + "dimensionless": { + "definition": "dimensionless", + "locked": true + }, + "length": { + "definition": "m", + "locked": true + }, + "mass": { + "definition": "kg", + "locked": true + }, + "time": { + "definition": "s", + "locked": true + }, + "current": { + "definition": "A", + "locked": true + }, + "temperature": { + "definition": "K", + "locked": true + }, + "luminosity": { + "definition": "cd", + "locked": true + }, + "luminous_flux": { + "definition": "lm", + "locked": true + }, + "substance": { + "definition": "mol", + "locked": true + }, + "angle": { + "definition": "rad", + "locked": true + }, + "information": { + "definition": "bit", + "locked": true + }, + "force": { + "definition": "N", + "locked": true + }, + "energy": { + "definition": "J", + "locked": true + }, + "power": { + "definition": "W", + "locked": true + }, + "pressure": { + "definition": "Pa", + "locked": true + }, + "charge": { + "definition": "C", + "locked": true + }, + "resistance": { + "definition": "\u03a9", + "locked": true + }, + "conductance": { + "definition": "S", + "locked": true + }, + "inductance": { + "definition": "H", + "locked": true + }, + "magnetic_flux": { + "definition": "Wb", + "locked": true + }, + "magnetic_field": { + "definition": "T", + "locked": true + }, + "frequency": { + "definition": "Hz", + "locked": true + }, + "luminance": { + "definition": "nit", + "locked": true + }, + "illuminance": { + "definition": "lx", + "locked": true + }, + "electric_potential": { + "definition": "V", + "locked": true + }, + "capacitance": { + "definition": "F", + "locked": true + }, + "activity": { + "definition": "kat", + "locked": true + } + } + }, + "AU": { + "label": "Hartree atomic units (AU)", + "units": { + "dimensionless": { + "definition": "dimensionless", + "locked": true + }, + "length": { + "definition": "bohr", + "locked": true + }, + "mass": { + "definition": "m_e", + "locked": true + }, + "time": { + "definition": "atomic_unit_of_time", + "locked": true + }, + "current": { + "definition": "atomic_unit_of_current", + "locked": true + }, + "temperature": { + "definition": "atomic_unit_of_temperature", + "locked": true + }, + "luminosity": { + "definition": "cd", + "locked": false + }, + "luminous_flux": { + "definition": "lm", + "locked": false + }, + "substance": { + "definition": "mol", + "locked": false + }, + "angle": { + "definition": "rad", + "locked": false + }, + "information": { + "definition": "bit", + "locked": false + }, + "force": { + "definition": "atomic_unit_of_force", + "locked": true + }, + "energy": { + "definition": "Ha", + "locked": true + }, + "power": { + "definition": "W", + "locked": false + }, + "pressure": { + "definition": "atomic_unit_of_pressure", + "locked": true + }, + "charge": { + "definition": "C", + "locked": false + }, + "resistance": { + "definition": "\u03a9", + "locked": false + }, + "conductance": { + "definition": "S", + "locked": false + }, + "inductance": { + "definition": "H", + "locked": false + }, + "magnetic_flux": { + "definition": "Wb", + "locked": false + }, + "magnetic_field": { + "definition": "T", + "locked": false + }, + "frequency": { + "definition": "Hz", + "locked": false + }, + "luminance": { + "definition": "nit", + "locked": false + }, + "illuminance": { + "definition": "lx", + "locked": false + }, + "electric_potential": { + "definition": "V", + "locked": false + }, + "capacitance": { + "definition": "F", + "locked": false + }, + "activity": { + "definition": "kat", + "locked": false + } + } + } + }, "selected": "Custom" }, "entry": { diff --git a/gui/yarn.lock b/gui/yarn.lock index 4e3b8b13818127d494749358f3cf600a1e9389ad..856f5ebd87c3d956d1f1e0cfc84b4474fbd0337a 100644 --- a/gui/yarn.lock +++ b/gui/yarn.lock @@ -2003,6 +2003,14 @@ lodash "^4.17.15" redent "^3.0.0" +"@testing-library/react-hooks@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12" + integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@testing-library/react@^12.1.5": version "12.1.5" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" @@ -13556,6 +13564,13 @@ react-error-boundary@4.0.3: dependencies: "@babel/runtime" "^7.12.5" +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-overlay@6.0.9, react-error-overlay@^6.0.9: version "6.0.9" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a" diff --git a/nomad/app/v1/routers/systems.py b/nomad/app/v1/routers/systems.py index 33728d94e98a15f437d2ba4bb56e819bed3b6de2..1aeeb7af5d0826245ebb78e1007a18d8768e5156 100644 --- a/nomad/app/v1/routers/systems.py +++ b/nomad/app/v1/routers/systems.py @@ -15,7 +15,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from typing import Union, Dict, List +from typing import Dict, List, Union from io import StringIO, BytesIO from collections import OrderedDict from enum import Enum @@ -30,7 +30,7 @@ from MDAnalysis.coordinates.PDB import PDBWriter from nomad.units import ureg from nomad.utils import strip, deep_get, query_list_to_dict -from nomad.atomutils import Formula +from nomad.atomutils import Formula, wrap_positions, unwrap_positions from nomad.normalizing.common import ase_atoms_from_nomad_atoms, mda_universe_from_nomad_atoms from nomad.datamodel.metainfo.simulation.system import Atoms as NOMADAtoms from .entries import answer_entry_archive_request @@ -183,6 +183,25 @@ class TempFormatEnum(str, Enum): FormatEnum = TempFormatEnum("FormatEnum", {format: format for format in format_map.keys()}) # type: ignore +wrap_mode_map: Dict[str, dict] = OrderedDict({ + 'original': {'description': 'The original positions as set in the data'}, + 'wrap': {'description': 'Positions are wrapped to be inside the cell respecting periodic boundary conditions'}, + 'unwrap': {'description': strip(''' + Positions are reconstructed so that the structure is not split by + periodic cell boundaries. Note that this produces meaningful results + only if the system dimensions are smaller than the unit cell. + ''')}, +}) + + +class TempWrapModeEnum(str, Enum): + pass + + +WrapModeEnum = TempWrapModeEnum("WrapModeEnum", {wrap_mode: wrap_mode for wrap_mode in wrap_mode_map.keys()}) # type: ignore +wrap_mode_description = "\n".join([f'- `{key}`: {value["description"]}' for [key, value] in wrap_mode_map.items()]) + + _file_response = status.HTTP_200_OK, { 'content': {'application/octet-stream': {}}, 'description': strip(''' @@ -237,10 +256,21 @@ Here is a brief rundown of the different features each format supports: {format_features}''' ), + wrap_mode: WrapModeEnum = Query( # type: ignore + default=WrapModeEnum.original, + description=f'''Determines how to handle atomic positions for the requested system. The available options are: + +{wrap_mode_description} + + '''), user: User = Depends(create_user_dependency(signature_token_auth_allowed=True))): ''' Build and retrieve a structure file containing an atomistic system stored - within an entry. Note that some formats are more restricted and cannot fully + within an entry. + + All length dimensions in the structure files are in Ångstroms (=1e-10 meter). + + Note that some formats are more restricted and cannot fully describe certains kinds of systems. For examples some entries within NOMAD do not contain a unit cell (e.g. molecules), whereas some formats require it to be present. @@ -323,6 +353,20 @@ Here is a brief rundown of the different features each format supports: except Exception: pass + # Handle wrap mode + if wrap_mode == WrapModeEnum.wrap: + atoms.positions = wrap_positions( + atoms.positions.magnitude, + atoms.lattice_vectors.magnitude, + atoms.periodic + ) + elif wrap_mode == WrapModeEnum.unwrap: + atoms.positions = unwrap_positions( + atoms.positions.magnitude, + atoms.lattice_vectors.magnitude, + atoms.periodic + ) + content = format_info['writer'](atoms, entry_id, formula) except Exception as e: raise HTTPException( diff --git a/nomad/atomutils.py b/nomad/atomutils.py index a584e5ec2b432fadf88dc2d777f2f3c02c8eadb1..933720a572016a260fbceae2363a47cc4b3cf58f 100644 --- a/nomad/atomutils.py +++ b/nomad/atomutils.py @@ -112,51 +112,158 @@ def is_valid_basis(basis: NDArray[Any]) -> bool: return True +def translate_pretty( + fractional: NDArray[Any], + pbc: Union[bool, NDArray[Any]]) -> NDArray[Any]: + """Translates atoms such that fractional positions are minimized.""" + pbc = pbc2pbc(pbc) + + for i in range(3): + if not pbc[i]: + continue + + indices = np.argsort(fractional[:, i]) + sp = fractional[indices, i] + + widths = (np.roll(sp, 1) - sp) % 1.0 + fractional[:, i] -= sp[np.argmin(widths)] + fractional[:, i] %= 1.0 + return fractional + + +def get_center_of_positions( + positions: NDArray[Any], + cell: NDArray[Any] = None, + pbc: Union[bool, NDArray[Any]] = True, + weights=None, + relative=False) -> NDArray[Any]: + """Calculates the center of positions with the given weighting. Also takes + the periodicity of the system into account. + + The algorithm is replicated from: + https://en.wikipedia.org/wiki/Center_of_mass#Systems_with_periodic_boundary_conditions + + Args: + positions: Positions of the atoms. Whether these are cartesian or + relative is controlled by the 'relative' argument. + cell: Unit cell + pbc: Periodic boundary conditions + relative: If true, the input and output positions are given relative to + the unit cell. Otherwise the positions are cartesian. + + Returns: + The position of the center of mass in the given system. + """ + pbc = pbc2pbc(pbc) + relative_positions = positions if relative else to_scaled(positions, cell) + + rel_com = np.zeros((1, 3)) + for i_comp in range(3): + i_pbc = pbc[i_comp] + i_pos = relative_positions[:, i_comp] + if i_pbc: + theta = i_pos * 2 * np.pi + xi = np.cos(theta) + zeta = np.sin(theta) + if weights: + xi *= weights + zeta *= weights + + xi_mean = np.mean(xi) + zeta_mean = np.mean(zeta) + + mean_theta = np.arctan2(-zeta_mean, -xi_mean) + np.pi + com_rel = mean_theta / (2 * np.pi) + rel_com[0, i_comp] = com_rel + else: + if weights: + total_weight = np.sum(weights) + rel_com[0, i_comp] = np.sum(i_pos * weights) / total_weight + else: + rel_com[0, i_comp] = np.sum(i_pos) + + return rel_com if relative else to_cartesian(rel_com, cell) + + def wrap_positions( positions: NDArray[Any], cell: NDArray[Any] = None, pbc: Union[bool, NDArray[Any]] = True, center: NDArray[Any] = [0.5, 0.5, 0.5], - eps: float = 1e-12) -> NDArray[Any]: + pretty_translation=False, + eps: float = 1e-12, + relative=False) -> NDArray[Any]: ''' - Wraps the given position so that they are within the unit cell. If no - cell is given, scaled positions are assumed. For wrapping cartesian - positions you also need to provide the cell. + Wraps the given position so that they are within the unit cell. Args: - positions: Positions of the atoms. Accepts both scaled and - cartesian positions. - cell: Lattice vectors for wrapping cartesian positions. + positions: Positions of the atoms. Whether these are cartesian or + relative is controlled by the 'relative' argument. + cell: Lattice vectors. pbc: For each axis in the unit cell decides whether the positions will be wrapped along this axis. center: The position in fractional coordinates that the wrapped positions will be nearest possible to. eps: Small number to prevent slightly negative coordinates from being wrapped. + relative: If true, the input and output positions are given relative to + the unit cell. Otherwise the positions are cartesian. ''' - if not hasattr(center, '__len__'): - center = (center,) * 3 - pbc = pbc2pbc(pbc) shift = np.asarray(center) - 0.5 - eps # Don't change coordinates when pbc is False shift[np.logical_not(pbc)] = 0.0 - if cell is None: - fractional = positions - else: - fractional = to_scaled(positions, cell) - fractional -= shift - - for i, periodic in enumerate(pbc): - if periodic: - fractional[:, i] %= 1.0 - fractional[:, i] += shift[i] - if cell is not None: - return np.dot(fractional, cell) + relative_pos = positions if relative else to_scaled(positions, cell) + relative_pos -= shift + + if pretty_translation: + relative_pos = translate_pretty(relative_pos, pbc) + shift = np.asarray(center) - 0.5 + shift[np.logical_not(pbc)] = 0.0 + relative_pos += shift else: - return fractional + for i, periodic in enumerate(pbc): + if periodic: + relative_pos[:, i] %= 1.0 + relative_pos[:, i] += shift[i] + + return relative_pos if relative else to_cartesian(relative_pos, cell) + + +def unwrap_positions( + positions: NDArray[Any], + cell: NDArray[Any], + pbc: Union[bool, NDArray[Any]], + relative=False) -> NDArray[Any]: + ''' + Unwraps the given positions so that continuous structures are not broken by + cell boundaries. + + Args: + positions: Positions of the atoms. Whether these are cartesian or + relative is controlled by the 'relative' argument. + cell: Lattice vectors. + pbc: For each axis in the unit cell decides whether the positions will + be wrapped along this axis. + center: The position in fractional coordinates that the wrapped + positions will be nearest possible to. + eps: Small number to prevent slightly negative coordinates from being + wrapped. + relative: If true, the input and output positions are given relative to + the unit cell. Otherwise the positions are cartesian. + ''' + pbc = pbc2pbc(pbc) + if not any(pbc): + return positions + + relative_pos = positions if relative else to_scaled(positions, cell) + center_of_pos = get_center_of_positions(relative_pos, pbc=pbc, relative=True) + relative_shifted = relative_pos + ([[0.5, 0.5, 0.5]] - center_of_pos) + wrapped_relative_pos = wrap_positions(relative_shifted, pbc=pbc, relative=True) + + return wrapped_relative_pos if relative else to_cartesian(wrapped_relative_pos, cell) def chemical_symbols(atomic_numbers: Iterable[int]) -> List[str]: diff --git a/nomad/cli/dev.py b/nomad/cli/dev.py index 3540230f6c61b12bc4745427b89a30022286622f..daa14ebf65d058576a089bd01e31b083ff54b5dc 100644 --- a/nomad/cli/dev.py +++ b/nomad/cli/dev.py @@ -482,27 +482,6 @@ def _generate_units_json(all_metainfo) -> Tuple[Any, Any]: # Some units need to be added manually. unit_list.extend([ - # Gigapascal - { - 'name': 'gigapascal', - 'dimension': 'pressure', - 'label': 'Gigapascal', - 'abbreviation': 'GPa', - }, - # Millibar - { - 'name': 'millibar', - 'dimension': 'pressure', - 'label': 'Millibar', - 'abbreviation': 'mbar', - }, - # Femtosecond - { - 'name': 'femtosecond', - 'dimension': 'time', - 'label': 'Femtosecond', - 'abbreviation': 'fs', - }, # Kilogram as SI base unit { 'name': 'kilogram', diff --git a/nomad/config/__init__.py b/nomad/config/__init__.py index f69e06da3760fa76315268f41d0e16aea0ec695f..cec9942fb619587d3f7cccce91520e3ea8fbcec8 100644 --- a/nomad/config/__init__.py +++ b/nomad/config/__init__.py @@ -493,7 +493,7 @@ def _check_config(): ui.north_base = f'{"https" if services.https else "http"}://{north.hub_host}:{north.hub_port}{services.api_base_path.rstrip("/")}/north' -def _merge(a: Union[dict, BaseModel], b: Union[dict, BaseModel], path: List[str] = None) -> Union[dict, BaseModel]: +def _merge(a: Union[dict, BaseModel], b: Union[dict, BaseModel]) -> Union[dict, BaseModel]: ''' Recursively merges b into a. Will add new key-value pairs, and will overwrite existing key-value pairs. Notice that this mutates the original @@ -513,13 +513,17 @@ def _merge(a: Union[dict, BaseModel], b: Union[dict, BaseModel], path: List[str] def get(target, key): return target[key] if isinstance(target, dict) else getattr(target, key) - if path is None: path = [] - for key in b.__dict__ if isinstance(b, BaseModel) else b: + # None values are ignored + if b is None: + return a + for key in (b.__dict__ if isinstance(b, BaseModel) else b): value = get(b, key) if has(a, key): child = get(a, key) - if isinstance(value, (BaseModel, dict)) and isinstance(child, (BaseModel, dict)): - _merge(child, value, path + [str(key)]) + # Objects are merged + if isinstance(value, (BaseModel, dict)) or isinstance(child, (BaseModel, dict)): + _merge(child, value) + # Other types are replaced else: set(a, key, value) else: diff --git a/nomad/config/models.py b/nomad/config/models.py index 89fdb8cd62494f39d4f17be9c1af7fef64a41a28..16b3cad8e7c07e281db1d8cfcc5eac3c3467cd14 100644 --- a/nomad/config/models.py +++ b/nomad/config/models.py @@ -105,7 +105,7 @@ class Options(OptionsBase): '''Common configuration class used for enabling/disabling certain elements and defining the configuration of each element. ''' - options: Dict[str, Any] = Field({}, description='Contains the available options.') + options: Optional[Dict[str, Any]] = Field({}, description='Contains the available options.') def filtered_keys(self) -> List[str]: '''Returns a list of keys that fullfill the include/exclude @@ -700,15 +700,120 @@ class Archive(NomadSettings): ''') -class UnitSystemEnum(str, Enum): - CUSTOM = 'Custom' - SI = 'SI' - AU = 'AU' +class UnitSystemUnit(StrictSettings): + definition: str = Field(description=''' + The unit definition. Can be a mathematical expression that combines + several units, e.g. `(kg * m) / s^2`. You should only use units that are + registered in the NOMAD unit registry (`nomad.units.ureg`). + ''') + locked: Optional[bool] = Field(False, description='Whether the unit is locked in the unit system it is defined in.') + + +dimensions = [ + # Base units + 'dimensionless', + 'length', + 'mass', + 'time', + 'current', + 'temperature', + 'luminosity', + 'luminous_flux', + 'substance', + 'angle', + 'information', + # Derived units with specific name + 'force', + 'energy', + 'power', + 'pressure', + 'charge', + 'resistance', + 'conductance', + 'inductance', + 'magnetic_flux', + 'magnetic_field', + 'frequency', + 'luminance', + 'illuminance', + 'electric_potential', + 'capacitance', + 'activity' +] +dimension_list = '\n'.join([' - ' + str(dim) for dim in dimensions]) + + +class UnitSystem(StrictSettings): + label: str = Field(description='Short, descriptive label used for this unit system.') + units: Optional[Dict[str, UnitSystemUnit]] = Field(description=f''' + Contains a mapping from each dimension to a unit. If a unit is not + specified for a dimension, the SI equivalent will be used by default. + The following dimensions are available: + {dimension_list} + ''') + + @root_validator(pre=True) + def __dimensions_and_si_defaults(cls, values): # pylint: disable=no-self-argument + '''Adds SI defaults for dimensions that are missing a unit.''' + units = values.get('units', {}) + from nomad.units import ureg + from pint import UndefinedUnitError + + # Check that only supported dimensions and units are used + for key in units.keys(): + if key not in dimensions: + raise AssertionError(f'Unsupported dimension "{key}" used in a unit system. The supported dimensions are: {dimensions}.') + + # Fill missing units with SI defaults + SI = { + 'dimensionless': 'dimensionless', + 'length': 'm', + 'mass': 'kg', + 'time': 's', + 'current': 'A', + 'temperature': 'K', + 'luminosity': 'cd', + 'luminous_flux': 'lm', + 'substance': 'mol', + 'angle': 'rad', + 'information': 'bit', + 'force': 'N', + 'energy': 'J', + 'power': 'W', + 'pressure': 'Pa', + 'charge': 'C', + 'resistance': 'Ω', + 'conductance': 'S', + 'inductance': 'H', + 'magnetic_flux': 'Wb', + 'magnetic_field': 'T', + 'frequency': 'Hz', + 'luminance': 'nit', + 'illuminance': 'lx', + 'electric_potential': 'V', + 'capacitance': 'F', + 'activity': 'kat' + } + for dimension in dimensions: + if dimension not in units: + units[dimension] = {'definition': SI[dimension]} + + # Check that units are available in registry, and thus also in the GUI. + for value in units.values(): + definition = value['definition'] + try: + ureg.Unit(definition) + except UndefinedUnitError as e: + raise AssertionError(f'Unsupported unit "{definition}" used in a unit registry.') + + values['units'] = units + + return values -class UnitSystems(StrictSettings): - '''Controls the used unit system.''' - selected: UnitSystemEnum = Field(description='Controls the default unit system.') +class UnitSystems(OptionsSingle): + '''Controls the available unit systems.''' + options: Optional[Dict[str, UnitSystem]] = Field(description='Contains the available unit systems.') class Theme(StrictSettings): @@ -731,7 +836,7 @@ class Card(StrictSettings): class Cards(Options): '''Contains the overview page card definitions and controls their visibility.''' - options: Dict[str, Card] = Field(description='Contains the available card options.') + options: Optional[Dict[str, Card]] = Field(description='Contains the available card options.') class Entry(StrictSettings): @@ -779,7 +884,7 @@ class Columns(OptionsMulti): Contains column definitions, controls their availability and specifies the default selection. ''' - options: Dict[str, Column] = Field(description=''' + options: Optional[Dict[str, Column]] = Field(description=''' All available column options. Note here that the key must correspond to a quantity path that exists in the metadata. ''') @@ -830,7 +935,7 @@ class FilterMenuActionCheckbox(FilterMenuAction): class FilterMenuActions(Options): '''Contains filter menu action definitions and controls their availability.''' - options: Dict[str, FilterMenuActionCheckbox] = Field( + options: Optional[Dict[str, FilterMenuActionCheckbox]] = Field( description='Contains options for filter menu actions.' ) @@ -852,7 +957,7 @@ class FilterMenu(StrictSettings): class FilterMenus(Options): '''Contains filter menu definitions and controls their availability.''' - options: Dict[str, FilterMenu] = Field(description='Contains the available filter menu options.') + options: Optional[Dict[str, FilterMenu]] = Field(description='Contains the available filter menu options.') class Schemas(OptionsBase): @@ -1003,7 +1108,7 @@ class App(StrictSettings): class Apps(Options): '''Contains App definitions and controls their availability.''' - options: Dict[str, App] = Field(description='Contains the available app options.') + options: Optional[Dict[str, App]] = Field(description='Contains the available app options.') class ExampleUploads(OptionsBase): @@ -1021,7 +1126,85 @@ class UI(StrictSettings): description='Controls the site theme and identity.' ) unit_systems: UnitSystems = Field( - UnitSystems(**{'selected': 'Custom'}), + UnitSystems(**{ + 'selected': 'Custom', + 'options': { + 'Custom': { + 'label': 'Custom', + 'units': { + 'length': {'definition': 'Å'}, + 'time': {'definition': 'fs'}, + 'energy': {'definition': 'eV'}, + 'pressure': {'definition': 'GPa'}, + 'angle': {'definition': '°'}, + } + }, + 'SI': { + 'label': 'International System of Units (SI)', + 'units': { + 'dimensionless': {'definition': 'dimensionless', 'locked': True}, + 'length': {'definition': 'm', 'locked': True}, + 'mass': {'definition': 'kg', 'locked': True}, + 'time': {'definition': 's', 'locked': True}, + 'current': {'definition': 'A', 'locked': True}, + 'temperature': {'definition': 'K', 'locked': True}, + 'luminosity': {'definition': 'cd', 'locked': True}, + 'luminous_flux': {'definition': 'lm', 'locked': True}, + 'substance': {'definition': 'mol', 'locked': True}, + 'angle': {'definition': 'rad', 'locked': True}, + 'information': {'definition': 'bit', 'locked': True}, + 'force': {'definition': 'N', 'locked': True}, + 'energy': {'definition': 'J', 'locked': True}, + 'power': {'definition': 'W', 'locked': True}, + 'pressure': {'definition': 'Pa', 'locked': True}, + 'charge': {'definition': 'C', 'locked': True}, + 'resistance': {'definition': 'Ω', 'locked': True}, + 'conductance': {'definition': 'S', 'locked': True}, + 'inductance': {'definition': 'H', 'locked': True}, + 'magnetic_flux': {'definition': 'Wb', 'locked': True}, + 'magnetic_field': {'definition': 'T', 'locked': True}, + 'frequency': {'definition': 'Hz', 'locked': True}, + 'luminance': {'definition': 'nit', 'locked': True}, + 'illuminance': {'definition': 'lx', 'locked': True}, + 'electric_potential': {'definition': 'V', 'locked': True}, + 'capacitance': {'definition': 'F', 'locked': True}, + 'activity': {'definition': 'kat', 'locked': True} + } + }, + 'AU': { + 'label': 'Hartree atomic units (AU)', + 'units': { + 'dimensionless': {'definition': 'dimensionless', 'locked': True}, + 'length': {'definition': 'bohr', 'locked': True}, + 'mass': {'definition': 'm_e', 'locked': True}, + 'time': {'definition': 'atomic_unit_of_time', 'locked': True}, + 'current': {'definition': 'atomic_unit_of_current', 'locked': True}, + 'temperature': {'definition': 'atomic_unit_of_temperature', 'locked': True}, + 'luminosity': {'definition': 'cd', 'locked': False}, + 'luminous_flux': {'definition': 'lm', 'locked': False}, + 'substance': {'definition': 'mol', 'locked': False}, + 'angle': {'definition': 'rad', 'locked': False}, + 'information': {'definition': 'bit', 'locked': False}, + 'force': {'definition': 'atomic_unit_of_force', 'locked': True}, + 'energy': {'definition': 'Ha', 'locked': True}, + 'power': {'definition': 'W', 'locked': False}, + 'pressure': {'definition': 'atomic_unit_of_pressure', 'locked': True}, + 'charge': {'definition': 'C', 'locked': False}, + 'resistance': {'definition': 'Ω', 'locked': False}, + 'conductance': {'definition': 'S', 'locked': False}, + 'inductance': {'definition': 'H', 'locked': False}, + 'magnetic_flux': {'definition': 'Wb', 'locked': False}, + 'magnetic_field': {'definition': 'T', 'locked': False}, + 'frequency': {'definition': 'Hz', 'locked': False}, + 'luminance': {'definition': 'nit', 'locked': False}, + 'illuminance': {'definition': 'lx', 'locked': False}, + 'electric_potential': {'definition': 'V', 'locked': False}, + 'capacitance': {'definition': 'F', 'locked': False}, + 'activity': {'definition': 'kat', 'locked': False} + } + }, + } + }), description='Controls the available unit systems.' ) entry: Entry = Field( diff --git a/nomad/parsing/tabular.py b/nomad/parsing/tabular.py index 42c02e8b240b2e7928ba755dfe2e33b4b4385e0a..127c5ae77822f3f0dbefc17d40bd3f91a662be9c 100644 --- a/nomad/parsing/tabular.py +++ b/nomad/parsing/tabular.py @@ -247,9 +247,11 @@ class TableData(ArchiveSection): except AttributeError: continue section_to_write = section_to_entry - if not any(item.label == 'EntryData' for item in section_to_entry.m_def.all_base_sections): + if not any( + (item.label == 'EntryData' or item.label == 'ArchiveSection') + for item in section_to_entry.m_def.all_base_sections): logger.warning( - f"make sure to inherit from EntryData in your base sections in {section_to_entry.m_def.name}") + f"make sure to inherit from EntryData in the base sections of {section_to_entry.m_def.name}") if not is_quantity_def: pass # raise TabularParserError( @@ -277,8 +279,9 @@ class TableData(ArchiveSection): setattr(self, single_entry_section.split('/')[0], None) self.m_add_sub_section( self.m_def.all_properties[single_entry_section.split('/')[0]], target_section, -1) - from nomad.datamodel import EntryArchive, EntryMetadata + from nomad.datamodel import EntryArchive, EntryMetadata + section_to_entry.fill_archive_from_datafile = False child_archive = EntryArchive( data=section_to_entry, m_context=archive.m_context, @@ -313,7 +316,7 @@ class TableData(ArchiveSection): logger.warning(f"make sure to inherit from EntryData in your base sections in {section.name}") try: - mainfile_name = getattr(getattr(section.m_root(), 'metadata'), 'mainfile') + mainfile_name = getattr(child_sections[0], section.m_def.more.get('label_quantity', None)) except (AttributeError, TypeError): logger.info('could not extract the mainfile from metadata. Setting a default name.') mainfile_name = section.m_def.name @@ -346,12 +349,14 @@ class TableData(ArchiveSection): current_child_entry_name = [get_nested_value(first_child, segments), '.yaml'] except Exception: current_child_entry_name = archive.metadata.mainfile.split('.archive') + first_child.m_context = archive.m_context self.m_update_from_dict(first_child.m_to_dict()) for index, child_section in enumerate(child_sections): if ref_entry_name: ref_entry_name: str = child_section.m_def.more.get('label_quantity', None) - segments = ref_entry_name.split('#/data/')[1].split('/') + segments = ref_entry_name.split('#/data/')[1].split('/') if ref_entry_name.find( + '/') else ref_entry_name filename = f"{get_nested_value(child_section, segments)}.entry_data.archive.{file_type}" current_child_entry_name = [get_nested_value(child_section, segments), '.yaml'] else: @@ -367,6 +372,7 @@ class TableData(ArchiveSection): annotation = data_quantity_def.m_get_annotations('tabular_parser') if annotation: child_section.m_update_from_dict({annotation.m_definition.name: data_file}) + child_section.fill_archive_from_datafile = False child_archive = EntryArchive( data=child_section, m_context=archive.m_context, @@ -391,7 +397,7 @@ m_package.__init_metainfo__() def set_entry_name(quantity_def, child_section, index) -> str: if name := child_section.m_def.more.get('label_quantity', None): - entry_name = f"{child_section[name]}_{index}" + entry_name = f"{child_section[name.split('#/data/')[1]]}_{index}" elif isinstance(quantity_def.type, Reference): entry_name = f"{quantity_def.type._target_section_def.name}_{index}" else: @@ -674,7 +680,7 @@ def _strip_whitespaces_from_df_columns(df): cleaned_col_name = col_name.strip().split('.')[0] count = 0 for string in transformed_column_names.values(): - if cleaned_col_name in string: + if cleaned_col_name == string.split('.')[0]: count += 1 if count: transformed_column_names.update({col_name: f'{cleaned_col_name}.{count}'}) @@ -755,8 +761,8 @@ class TabularDataParser(MatchingParser): return None def is_mainfile( - self, filename: str, mime: str, buffer: bytes, decoded_buffer: str, - compression: str = None + self, filename: str, mime: str, buffer: bytes, decoded_buffer: str, + compression: str = None ) -> Union[bool, Iterable[str]]: # We use the main file regex capabilities of the superclass to check if this is a # .csv file diff --git a/nomad/search.py b/nomad/search.py index 3d6aa36270f94e5015cd7ce6607f3f6bd454ca27..56b1115adb4e123cbe544ff2edeba878de6cf43d 100644 --- a/nomad/search.py +++ b/nomad/search.py @@ -1412,6 +1412,7 @@ def search( ) doc_type = index.doc_type + skip_sort = False # The first half of this method creates the ES query. Then the query is run on ES. # The second half is about transforming the ES response to a MetadataResponse. @@ -1429,6 +1430,12 @@ def search( if isinstance(query, EsQuery): es_query = cast(EsQuery, query) else: + # TODO this is a temporary performance hot-fix. Sort is expensive and we sort + # by default. In the future, the client should explicitly state if sort is necessary. + # Now, we simply do never sort, if there is a top-level AND match for a single id in the query. + # In this case, there wil always be just one result and sorting is not necessary. + # This catches a lot of problematic queries as a hot-fix. + skip_sort = isinstance(query, dict) and isinstance(query.get(doc_type.id_field, None), str) query = normalize_api_query(cast(Query, query), doc_type=doc_type) es_query = create_es_query(cast(Query, query)) @@ -1446,7 +1453,8 @@ def search( pagination.order_by = doc_type.id_field sort, order_quantity, page_after_value = _api_to_es_sort(pagination, doc_type=doc_type) - search = search.sort(sort) + if not skip_sort: + search = search.sort(sort) search = search.extra(size=pagination.page_size, track_total_hits=True) if pagination.page_offset: diff --git a/tests/app/v1/routers/test_systems.py b/tests/app/v1/routers/test_systems.py index a0f506b1e4e2ad64dfc57fa901c186ac8880eebb..fb6e9cf832debc8a133d232926e41f50ba3d63f7 100644 --- a/tests/app/v1/routers/test_systems.py +++ b/tests/app/v1/routers/test_systems.py @@ -31,7 +31,7 @@ from nomad.datamodel.results import Results, Material, System from nomad.datamodel.metainfo.simulation.run import Run from nomad.datamodel.metainfo.simulation.system import System as SystemRun, Atoms from nomad.utils.exampledata import ExampleData -from nomad.app.v1.routers.systems import format_map, FormatFeature +from nomad.app.v1.routers.systems import format_map, FormatFeature, WrapModeEnum from .common import assert_response, assert_browser_download_headers @@ -90,6 +90,18 @@ atoms_missing_positions = Atoms( species=[7, 8], ) +atoms_wrap_mode = Atoms( + n_atoms=2, + labels=["C", "H"], + species=[6, 1], + positions=np.array([[-15, -15, -15], [17, 17, 17]]) * ureg.angstrom, + lattice_vectors=np.array([[5, 0, 0], [0, 5, 0], [0, 0, 5]]) * ureg.angstrom, + periodic=[True, True, True], +) + +atoms_wrap_mode_no_pbc = atoms_wrap_mode.m_copy() +atoms_wrap_mode_no_pbc.periodic = [False, False, False] + @pytest.fixture(scope="module") def example_data_systems(elastic_module, mongo_module, test_user): @@ -104,6 +116,8 @@ def example_data_systems(elastic_module, mongo_module, test_user): SystemRun(atoms=atoms_with_cell), SystemRun(atoms=atoms_missing_positions), SystemRun(atoms=atoms_without_cell), + SystemRun(atoms=atoms_wrap_mode), + SystemRun(atoms=atoms_wrap_mode_no_pbc), ])]) archive.results = Results( material=Material( @@ -133,8 +147,8 @@ def example_data_systems(elastic_module, mongo_module, test_user): assert search(query=dict(upload_id=upload_id)).pagination.total == 0 -def run_query(entry_id, path, format, client): - response = client.get(f'systems/{entry_id}/?path={path}&format={format}', headers={}) +def run_query(entry_id, path, format, client, wrap_mode=None): + response = client.get(f'systems/{entry_id}/?path={path}&format={format}{f"&wrap_mode={wrap_mode}" if wrap_mode else ""}', headers={}) return response @@ -332,3 +346,21 @@ def test_indices(path, filename, n_atoms, client, example_data_systems): ) atoms = ase.io.read(BytesIO(response.content), format='cif') assert len(atoms) == n_atoms + + +@pytest.mark.parametrize("path, wrap_mode, expected_positions", [ + pytest.param('/run/0/system/3', None, [[-15, -15, -15], [17, 17, 17]], id='default'), + pytest.param('/run/0/system/3', WrapModeEnum.original, [[-15, -15, -15], [17, 17, 17]], id='original'), + pytest.param('/run/0/system/3', WrapModeEnum.wrap, [[0, 0, 0], [2, 2, 2]], id='wrap, pbc=[1, 1, 1]'), + pytest.param('/run/0/system/4', WrapModeEnum.wrap, [[-15, -15, -15], [17, 17, 17]], id='wrap, pbc=[0, 0, 0]'), + pytest.param('/run/0/system/3', WrapModeEnum.unwrap, [[1.5, 1.5, 1.5], [3.5, 3.5, 3.5]], id='unwrap, pbc=[1, 1, 1]'), + pytest.param('/run/0/system/4', WrapModeEnum.unwrap, [[-15, -15, -15], [17, 17, 17]], id='unwrap, pbc=[0, 0, 0]'), +]) +def test_wrap_mode(path, wrap_mode, expected_positions, client, example_data_systems): + '''Test that the wrap_mode parameter is handled correctly. + ''' + response = run_query('systems_entry_1', path, 'xyz', client, wrap_mode) + assert_response(response, 200) + atoms = ase.io.read(StringIO(response.text), format='xyz') + positions = atoms.get_positions() + assert np.allclose(positions, expected_positions) diff --git a/tests/archive/test_archive.py b/tests/archive/test_archive.py index ddef2779087179faa221d383235c41ed012f9909..b1f6fc153d7ebc0e376afbf6329b3be68a5bcc46 100644 --- a/tests/archive/test_archive.py +++ b/tests/archive/test_archive.py @@ -291,6 +291,8 @@ def test_keys(key): assert key.strip() in query_archive(f, {key: '*'}) +@pytest.mark.skipif(config.normalize.springer_db_path is None, + reason='Springer DB path missing') def test_read_springer(): springer = read_archive(config.normalize.springer_db_path) with pytest.raises(KeyError): diff --git a/tests/normalizing/test_material.py b/tests/normalizing/test_material.py index a229738ead847782c50401a4330c13fea0c58db6..4bc716e11cca1103c005139b661493ee1a30dafd 100644 --- a/tests/normalizing/test_material.py +++ b/tests/normalizing/test_material.py @@ -23,7 +23,7 @@ import ase.build from matid.symmetry.wyckoffset import WyckoffSet # pylint: disable=import-error from nomad.units import ureg -from nomad import atomutils +from nomad import atomutils, config from nomad.utils import hash from nomad.normalizing.common import ase_atoms_from_nomad_atoms from nomad.datamodel.results import ElementalComposition @@ -149,7 +149,8 @@ def test_material_surface(surface): assert material.material_name is None assert material.symmetry is None - +@pytest.mark.skipif(config.normalize.springer_db_path is None, + reason='Springer DB path missing') def test_material_bulk(bulk): # Material material = bulk.results.material diff --git a/tests/normalizing/test_system.py b/tests/normalizing/test_system.py index 340a31df949b150dc998d4ac5f43689df6648125..bb0ae667fa86a8422d154b3a5331455096f4355e 100644 --- a/tests/normalizing/test_system.py +++ b/tests/normalizing/test_system.py @@ -382,6 +382,8 @@ def test_aflow_prototypes(): assert prototype_label == "186-SZn-hP4" +@pytest.mark.skipif(config.normalize.springer_db_path is None, + reason='Springer DB path missing') def test_springer_normalizer(): ''' Ensure the Springer normalizer works well with the VASP example.