From bef33475c794afe9328b0e38e9986ae05be78aaf Mon Sep 17 00:00:00 2001
From: Sandor Brockhauser <sandor.brockhauser@physik.hu-berlin.de>
Date: Mon, 10 Feb 2025 10:37:38 +0000
Subject: [PATCH] Added support for targeting any NeXus search quantity in
 aggregations by providing the data type at the end of the search quantity
 name.

Changelog: Added
---
 gui/src/components/plotting/common.js         |  81 +++++++------
 gui/src/components/search/Filter.js           |   9 +-
 gui/src/components/search/FilterRegistry.js   |  41 ++++++-
 gui/src/components/search/SearchContext.js    | 108 +++++++++++-------
 .../components/search/input/InputHistogram.js |  18 ++-
 .../components/search/input/InputMetainfo.js  |  23 +++-
 .../search/widgets/WidgetHistogram.js         |   1 +
 .../search/widgets/WidgetScatterPlot.js       |   4 +-
 .../search/widgets/WidgetScatterPlotEdit.js   |   2 +-
 .../components/search/widgets/WidgetTerms.js  |  10 +-
 gui/src/utils.js                              |  20 +++-
 nomad/metainfo/elasticsearch_extension.py     |  20 ++--
 nomad/metainfo/metainfo.py                    |  51 ++++-----
 nomad/search.py                               |  26 +----
 tests/fixtures/data.py                        |  38 ------
 15 files changed, 249 insertions(+), 203 deletions(-)

diff --git a/gui/src/components/plotting/common.js b/gui/src/components/plotting/common.js
index 1dd7a4a179..dd67e862f1 100644
--- a/gui/src/components/plotting/common.js
+++ b/gui/src/components/plotting/common.js
@@ -41,8 +41,9 @@ import {
   eachQuarterOfInterval
 } from 'date-fns'
 import { scale as chromaScale } from 'chroma-js'
-import { scale as scaleUtils, add, DType, formatNumber, getDisplayLabel, parseJMESPath } from '../../utils.js'
+import { scale as scaleUtils, add, DType, formatNumber, getDisplayLabel, parseQuantityName, parseJMESPath, getLabel } from '../../utils.js'
 import { Unit } from '../units/Unit'
+import { postFixMap } from '../search/FilterRegistry.js'
 
 export const scales = {
   'linear': 'linear',
@@ -497,43 +498,49 @@ export function getPlotTracesVertical(plots, theme) {
  * @param {object} units Units in current unit system.
  */
 export function getAxisConfig(axis, filterData, units) {
-    const {quantity} = parseJMESPath(axis?.search_quantity)
-    const filter = filterData[quantity]
-    const dtype = filter?.dtype
-    const unit = axis.unit
-      ? new Unit(axis.unit)
-      : new Unit(filter?.unit || 'dimensionless').toSystem(units)
-
-    // Create the final label
-    let title = axis.title || filter?.label || getDisplayLabel(filter)
-    let finalUnit
-    if (unit) {
-      finalUnit = new Unit(unit).label()
-    } else if (filter?.unit) {
-      finalUnit = new Unit(filter.unit).toSystem(units).label()
-    }
-    if (finalUnit) {
-      title = `${title} (${finalUnit})`
+  const {quantity} = parseJMESPath(axis?.search_quantity)
+  const filter = filterData[quantity]
+  let dtype = filter?.dtype
+  if (!dtype || dtype === DType.Unknown) {
+    const dtypeFromName = postFixMap[parseQuantityName(quantity)?.dtype]
+    if (dtypeFromName) {
+      dtype = dtypeFromName
     }
+  }
+  const unit = axis.unit
+    ? new Unit(axis.unit)
+    : new Unit(filter?.unit || 'dimensionless').toSystem(units)
+
+  // Create the final label
+  let title = axis.title || filter?.label || getDisplayLabel(filter) || getLabel(quantity)
+  let finalUnit
+  if (unit) {
+    finalUnit = new Unit(unit).label()
+  } else if (filter?.unit) {
+    finalUnit = new Unit(filter.unit).toSystem(units).label()
+  }
+  if (finalUnit) {
+    title = `${title} (${finalUnit})`
+  }
 
-    // Determine the final description
-    let description = axis.description || filter?.description || ''
-    if (description && quantity) {
-      description = (
-        <>
-          <Typography>{title}</Typography>
-          <b>Description: </b>{description}<br/>
-          <b>Path: </b>{quantity}
-        </>
-      )
-    }
+  // Determine the final description
+  let description = axis.description || filter?.description || ''
+  if (description && quantity) {
+    description = (
+      <>
+        <Typography>{title}</Typography>
+        <b>Description: </b>{description}<br/>
+        <b>Path: </b>{quantity}
+      </>
+    )
+  }
 
-    return {
-      ...axis,
-      description,
-      title,
-      unit,
-      dtype,
-      quantity
-    }
+  return {
+    ...axis,
+    description,
+    title,
+    unit,
+    dtype,
+    quantity
+  }
 }
diff --git a/gui/src/components/search/Filter.js b/gui/src/components/search/Filter.js
index fb518d9a4a..dd4d4782e0 100644
--- a/gui/src/components/search/Filter.js
+++ b/gui/src/components/search/Filter.js
@@ -15,7 +15,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { isNil, isArray, isEmpty, capitalize, split } from 'lodash'
+import { isNil, isArray, isEmpty } from 'lodash'
 import { searchQuantities } from '../../config'
 import {
   getDatatype,
@@ -24,7 +24,7 @@ import {
   getDisplayLabel,
   DType,
   multiTypes,
-  parseQuantityName
+  getLabel
 } from '../../utils'
 import { Unit } from '../units/Unit'
 
@@ -140,11 +140,6 @@ export class Filter {
     this.description = params?.description || def?.description
     this.unit = params?.unit || def?.unit
     this.dimension = def?.unit ? new Unit(def?.unit).dimension() : 'dimensionless'
-    function getLabel(quantity) {
-      if (isNil(quantity)) return ''
-      const {path} = parseQuantityName(quantity)
-      return capitalize(split(path, '.').slice(-1)[0].replace(/_/g, ' '))
-    }
     this.label = params?.label || getDisplayLabel(def) || getLabel(this.quantity)
     this.parent = parent
     this.group = params.group
diff --git a/gui/src/components/search/FilterRegistry.js b/gui/src/components/search/FilterRegistry.js
index 0f5755f9e3..4d91c63e73 100644
--- a/gui/src/components/search/FilterRegistry.js
+++ b/gui/src/components/search/FilterRegistry.js
@@ -36,6 +36,13 @@ const dtypeMap = {
   [DType.Enum]: 'str',
   [DType.Boolean]: 'bool'
 }
+export const postFixMap = {
+  'int': DType.Int,
+  'float': DType.Float,
+  'datetime': DType.Timestamp,
+  'str': DType.String,
+  'bool': DType.Boolean
+}
 
 /**
  * This function is used to register a new filter within the SearchContext.
@@ -732,6 +739,28 @@ export function getStaticSuggestions(quantities, filterData) {
   return suggestions
 }
 
+// add a specific filter
+export function addFilter(filterPath, def, repeats, filtersData, setFiltersData) {
+  if (filterPath in filtersData) {
+    return
+  }
+  const newFilters = {}
+  const pathDtype = filterPath.split(schemaSeparator).slice(-1)[0]
+  const dtype = dtypeMap[getDatatype(def)] || pathDtype
+  // TODO: For some Nexus quantities, the data types cannot be fetched.
+  if (!dtype) {
+    return
+  }
+  const params = {
+    name: filterPath,
+    quantity: filterPath,
+    aggregatable: new Set([DType.String, DType.Enum, DType.Boolean]).has(getDatatype(def)),
+    repeats: repeats
+  }
+  newFilters[filterPath] = new Filter(def, params)
+  setFiltersData((old) => ({...old, ...newFilters}))
+}
+
 /**
  * HOC that is used to preload search quantities from all required schemas. This
  * simplifies the rendering logic by first loading all schemas before rendering
@@ -744,7 +773,7 @@ export const withSearchQuantities = (WrappedComponent) => {
     const [yamlOptions, nexusOptions, initialFilterData] = useMemo(() => {
       const options = getOptions(initialSearchQuantities)
       const yamlOptions = options.filter((name) => name.includes(`#${yamlSchemaPrefix}`))
-      const nexusOptions = options.filter((name) => name.startsWith('nexus.'))
+      const nexusOptions = options.filter((name) => name.includes(`#pynxtools.nomad.schema`))
 
       // Perform glob filtering on default filters. Only exclude affects the
       // default filters.
@@ -786,14 +815,14 @@ export const withSearchQuantities = (WrappedComponent) => {
     // Nexus metainfo is loaded here once metainfo is ready
     useEffect(() => {
       if (!nexusOptions.length || !metainfo) return
-      const pkg = metainfo._packageDefs['nexus']
+      const pkg = metainfo._packageDefs['pynxtools.nomad.schema']
       const sections = pkg.section_definitions
       const nexusFilters = {}
       for (const section of sections) {
-        const sectionPath = `nexus.${section.name}`
+        const sectionPath = `data.${section.name}`
 
         // The NeXus section is skipped (it contains duplicate information)
-        if (sectionPath === 'nexus.NeXus') continue
+        if (sectionPath === 'data.NeXus') continue
 
         // Only applications definitions are loaded
         if (section?.more?.nx_category !== 'application') continue
@@ -806,8 +835,8 @@ export const withSearchQuantities = (WrappedComponent) => {
         // Add all included quantities recursively
         for (const [def, path, repeats] of getQuantities(section)) {
           const filterPath = `${sectionPath}.${path}`
-          const included = glob(filterPath, initialSearchQuantities?.include, initialSearchQuantities?.exclude)
-          if (!included) continue
+          // const included = glob(filterPath, initialSearchQuantities?.include, initialSearchQuantities?.exclude)
+          // if (!included) continue
           const dtype = dtypeMap[getDatatype(def)]
           // TODO: For some Nexus quantities, the data types cannot be fetched.
           if (!dtype) {
diff --git a/gui/src/components/search/SearchContext.js b/gui/src/components/search/SearchContext.js
index c54c7457ac..dcffd4fce2 100644
--- a/gui/src/components/search/SearchContext.js
+++ b/gui/src/components/search/SearchContext.js
@@ -72,7 +72,7 @@ import UploadStatusIcon from '../uploads/UploadStatusIcon'
 import { getWidgetsObject } from './widgets/Widget'
 import { inputSectionContext } from './input/InputNestedObject'
 import { SearchSuggestion } from './SearchSuggestion'
-import { withSearchQuantities } from './FilterRegistry'
+import { withSearchQuantities, addFilter } from './FilterRegistry'
 import { useUnitContext } from '../units/UnitContext'
 
 const useWidthConstrainedStyles = makeStyles(theme => ({
@@ -442,6 +442,7 @@ export const SearchContextRaw = React.memo(({
       useSetPagination,
       usePaginationState,
       useAgg,
+      useRemoveAgg,
       useAggs,
       useSetAggsResponse,
       useSetFilters,
@@ -1088,13 +1089,16 @@ export const SearchContextRaw = React.memo(({
       const key = useMemo(() => `${name}:${id}`, [name, id])
       const setAgg = useSetRecoilState(aggsFamily(key))
       const aggResponse = useRecoilValue(aggsResponseFamily(key))
-      const filtersData = useRecoilValue(filtersDataState)
+      const [filtersData, setFiltersData] = useRecoilState(filtersDataState)
 
       // Whenever the aggregation requirements change, create the final
       // aggregation config and set it in the search context: this will then
       // trigger any required API calls that also return the aggregation
       // response that is returned by this hook.
       useEffect(() => {
+        if (!(name in filtersData)) {
+          addFilter(name, "", true, filtersData, setFiltersData)
+        }
         const defaults = filtersData[name]?.aggs?.[config?.type]
         const finalConfig = {
           update: update,
@@ -1102,11 +1106,22 @@ export const SearchContextRaw = React.memo(({
           ...config
         }
         setAgg(finalConfig)
-      }, [name, update, setAgg, config, filtersData])
+      }, [name, update, setAgg, config, filtersData, setFiltersData])
 
       return aggResponse
     }
 
+    /**
+     * Function for removing an agg from use. Should be called when a component
+     * that uses an agg is unmounted.
+     */
+    const useRemoveAgg = () => {
+      return useRecoilCallback(({ reset }) => (name, id) => {
+        const key = `${name}:${id}`
+        reset(aggsFamilyRaw(key))
+      }, [])
+    }
+
     /**
      * This hook will expose a function for setting the values of all filters.
      *
@@ -1165,6 +1180,7 @@ export const SearchContextRaw = React.memo(({
       useRemoveWidget,
       useResetWidgets,
       useAgg,
+      useRemoveAgg,
       useSetFilters,
       useUpdateFilter,
       useQuery,
@@ -1234,31 +1250,37 @@ export const SearchContextRaw = React.memo(({
    */
   const resolve = useCallback(prop => {
     const {response, timestamp, queryChanged, paginationChanged, search, aggsToUpdate, resource, callbackAgg, callbackHits} = prop
-    const data = response.response
+    const error = !response
     const next = apiQueue.current[0]
     if (next !== timestamp) {
       apiMap.current[timestamp] = prop
       return
     }
-    // Update the aggregations if new aggregation data is received. The old
-    // aggregation data is preserved and new information is updated.
-    if (!isEmpty(data.aggregations)) {
-      const newAggs = convertAggAPIToGUI(data.aggregations, aggsToUpdate, resource, filtersData)
-      callbackAgg && callbackAgg(newAggs, undefined, true)
+    if (error) {
+      callbackAgg && callbackAgg(undefined, error, true)
+      callbackHits && callbackHits(undefined, error, true, undefined)
     } else {
-      callbackAgg && callbackAgg(undefined, undefined, false)
-    }
-    // Update the query results if new data is received.
-    if (queryChanged || paginationChanged) {
-      paginationResponse.current = data.pagination
-      const newResults = {
-        response: response,
-        pagination: combinePagination(search.pagination, data.pagination),
-        setPagination: setPagination
+      const data = response.response
+      // Update the aggregations if new aggregation data is received. The old
+      // aggregation data is preserved and new information is updated.
+      if (!isEmpty(data.aggregations)) {
+        const newAggs = convertAggAPIToGUI(data.aggregations, aggsToUpdate, resource, filtersData)
+        callbackAgg && callbackAgg(newAggs, undefined, true)
+      } else {
+        callbackAgg && callbackAgg(undefined, undefined, false)
+      }
+      // Update the query results if new data is received.
+      if (queryChanged || paginationChanged) {
+        paginationResponse.current = data.pagination
+        const newResults = {
+          response: response,
+          pagination: combinePagination(search.pagination, data.pagination),
+          setPagination: setPagination
+        }
+        callbackHits && callbackHits(newResults, undefined, true, search)
+      } else {
+        callbackHits && callbackHits(undefined, undefined, false, search)
       }
-      callbackHits && callbackHits(newResults, undefined, true, search)
-    } else {
-      callbackHits && callbackHits(undefined, undefined, false, search)
     }
     // Remove this query from queue and see if next can be resolved.
     apiQueue.current.shift()
@@ -1373,24 +1395,30 @@ export const SearchContextRaw = React.memo(({
     const timestamp = Date.now()
     apiQueue.current.push(timestamp)
     setApiQuery(search?.query)
+    const resolveArgs = {
+        timestamp,
+        queryChanged,
+        paginationChanged,
+        search,
+        aggsToUpdate,
+        resource,
+        callbackAgg,
+        callbackHits
+      }
     api.query(resource, search, {loadingIndicator: true, returnRequest: true})
-      .then((response) => {
-        return resolve({
-          response,
-          timestamp,
-          queryChanged,
-          paginationChanged,
-          search,
-          aggsToUpdate,
-          resource,
-          callbackAgg,
-          callbackHits
-        })
-      })
+      .then((response) => resolve({response, ...resolveArgs}))
       .catch((error) => {
-        raiseError(error)
-        callbackAgg && callbackAgg(undefined, error, true)
-        callbackHits && callbackHits(undefined, error, true, undefined)
+        let apiErrorMessage = error?.apiMessage?.[0]?.msg
+        if (apiErrorMessage) {
+          if (apiErrorMessage?.endsWith('is not a doc quantity')) {
+            const name = apiErrorMessage.split(' ')[0]
+            apiErrorMessage = `Could not find definition for the search quantity "${name}". Please remove it from the search and try again.`
+          }
+          error = Error(apiErrorMessage)
+          error.name = 'BadRequest'
+          raiseError(error)
+        }
+        resolve({undefined, ...resolveArgs})
       })
   }, [filtersData, filterDefaults, filtersLocked, resource, api, raiseError, resolve, dynamicQueryModes, setApiQuery])
 
@@ -1715,6 +1743,7 @@ export const SearchContextRaw = React.memo(({
       useResults,
       useHits,
       useAgg,
+      useRemoveAgg,
       useAggCall,
       useSetFilters,
       useUpdateFilter
@@ -1760,6 +1789,7 @@ export const SearchContextRaw = React.memo(({
     useApiQuery,
     useApiData,
     useAgg,
+    useRemoveAgg,
     useAggs,
     useQuery,
     useSetFilters,
@@ -2112,7 +2142,7 @@ function convertAggGUIToAPI(aggs, resource, filtersData) {
   for (const [key, agg] of Object.entries(aggs)) {
     const filterName = rsplit(key, ':', 1)[0]
     if (agg.update) {
-      const exclusive = filtersData[filterName].exclusive
+      const exclusive = filterName in filtersData && filtersData[filterName].exclusive
       const type = agg.type
       const apiAgg = apiAggs[key] || {}
       const aggSet = agg.set
@@ -2266,7 +2296,7 @@ function reduceAggs(aggs, oldAggs, queryChanged, updatedFilters, filtersData) {
     // If the filter is exclusive, and ONLY it has been modified in this
     // query, we do not update it's aggregation.
     const exclude = isNil(agg.exclude_from_search)
-      ? filtersData[filter_name].exclusive
+      ? filter_name in filtersData && filtersData[filter_name].exclusive
       : agg.exclude_from_search
     if (exclude && updatedFilters.has(filter_name) && updatedFilters.size === 1) {
       update = false
diff --git a/gui/src/components/search/input/InputHistogram.js b/gui/src/components/search/input/InputHistogram.js
index d90e7e5b71..1a9fd25933 100644
--- a/gui/src/components/search/input/InputHistogram.js
+++ b/gui/src/components/search/input/InputHistogram.js
@@ -59,7 +59,7 @@ export const Histogram = React.memo(({
   classes,
   'data-testid': testID
 }) => {
-  const {filterData, useAgg, useFilterState} = useSearchContext()
+  const {filterData, useAgg, useRemoveAgg, useFilterState} = useSearchContext()
   const sectionContext = useContext(inputSectionContext)
   const repeats = sectionContext?.repeats
   const styles = useStyles({classes})
@@ -80,6 +80,18 @@ export const Histogram = React.memo(({
   const [maxInclusive, setMaxInclusive] = useState(true)
   const highlight = Boolean(filter)
 
+  // When component is unmounted, remove aggregation requests
+  const quantityName = x.search_quantity
+  const aggIdHistogram = `${aggId}_histogram`
+  const aggIdSlider = `${aggId}_slider`
+  const removeAgg = useRemoveAgg()
+  useEffect(() => {
+    return () => {
+      removeAgg(quantityName, aggIdHistogram)
+      removeAgg(quantityName, aggIdSlider)
+    }
+  }, [removeAgg, quantityName, aggIdHistogram, aggIdSlider])
+
   // Determine the description and units
   const def = filterData[x.search_quantity]
   const unitStorage = useMemo(() => { return new Unit(def?.unit || 'dimensionless') }, [def])
@@ -161,7 +173,7 @@ export const Histogram = React.memo(({
       ? {type: 'histogram', buckets: nBins, exclude_from_search, extended_bounds}
       : {type: 'histogram', interval: discretization, exclude_from_search, extended_bounds}
   }, [filter, fromDisplayUnit, isTime, discretization, nBins, autorange])
-  const agg = useAgg(x.search_quantity, visible && showStatistics, `${aggId}_histogram`, aggHistogramConfig)
+  const agg = useAgg(quantityName, visible && showStatistics, aggIdHistogram, aggHistogramConfig)
   useEffect(() => {
     if (!isNil(agg)) {
       firstLoad.current = false
@@ -171,7 +183,7 @@ export const Histogram = React.memo(({
   // Aggregation when the statistics are disabled: a simple min_max aggregation
   // is enough in order to get the slider range.
   const aggSliderConfig = useMemo(() => ({type: 'min_max', exclude_from_search: true}), [])
-  const aggSlider = useAgg(x.search_quantity, visible && !showStatistics, `${aggId}_slider`, aggSliderConfig)
+  const aggSlider = useAgg(quantityName, visible && !showStatistics, aggIdSlider, aggSliderConfig)
 
   // Determine the global minimum and maximum values
   const [minGlobal, maxGlobal] = useMemo(() => {
diff --git a/gui/src/components/search/input/InputMetainfo.js b/gui/src/components/search/input/InputMetainfo.js
index 4263809e38..3eced926ca 100644
--- a/gui/src/components/search/input/InputMetainfo.js
+++ b/gui/src/components/search/input/InputMetainfo.js
@@ -27,11 +27,14 @@ import React, {
 import { makeStyles } from '@material-ui/core/styles'
 import PropTypes from 'prop-types'
 import { Tooltip, List, ListItemText, ListSubheader } from '@material-ui/core'
+import { has } from 'lodash'
 import HelpOutlineIcon from '@material-ui/icons/HelpOutline'
 import { getSchemaAbbreviation, getSuggestions, parseJMESPath } from '../../../utils'
 import { useSearchContext } from '../SearchContext'
 import { VariableSizeList } from 'react-window'
 import { InputText } from './InputText'
+import { dtypeSeparator } from '../../../config'
+import { postFixMap } from '../FilterRegistry'
 
 /**
  * Wrapper around InputText that is specialized in showing metainfo options. The
@@ -86,13 +89,15 @@ export const InputMetainfoControlled = React.memo(({
     }
     if (validate) {
       return validate(value)
-    } else if (!(keysSet.has(value))) {
+    // Check if the quantity is available. The check is ignored if the quantity
+    // explicitly contains the data type as a postfix.
+    } else if (!(keysSet.has(value)) && !hasDatatype(value)) {
       return {valid: false, error: `The quantity "${value}" is not available.`}
     }
     return {valid: true, error: undefined}
   }, [validate, keysSet, optional, disableValidation])
 
-  // Handles the selectance of a suggested value
+  // Handles the selection of a suggested value
   const handleSelect = useCallback((key) => {
     onSelect?.(key, options[key])
   }, [onSelect, options])
@@ -223,15 +228,15 @@ export const InputJMESPath = React.memo(React.forwardRef(({
     if (errorParse) {
       return {valid: false, error: 'Invalid JMESPath query, please check your syntax.'}
     }
-    if (!(keysSet.has(quantity))) {
+    if (!(keysSet.has(quantity)) && !hasDatatype(quantity)) {
       return {valid: false, error: `The quantity "${quantity}" is not available.`}
     }
     for (const extra of extras) {
-      if (!filterData[extra]) {
+      if (!filterData[extra] && !hasDatatype(extra)) {
         return {valid: false, error: `The quantity "${extra}" is not available.`}
       }
     }
-    if (filterData[quantity].repeats_section && quantity === path + schema) {
+    if (filterData[quantity]?.repeats_section && quantity === path + schema) {
       return {valid: false, error: `The quantity "${quantity}" is contained in at least one repeatable section. Please use JMESPath syntax to select one or more target sections.`}
     }
     return {valid: true, error: undefined}
@@ -436,3 +441,11 @@ function getMetainfoOptions(filterData, dtypes, dtypesRepeatable, disableNonAggr
     }])
   )
 }
+
+/**
+ * Checks if the given quantity name contains the data type as a postfix.
+*/
+function hasDatatype(quantity) {
+  const postFix = quantity.split(dtypeSeparator).pop()
+  return has(postFixMap, postFix)
+}
diff --git a/gui/src/components/search/widgets/WidgetHistogram.js b/gui/src/components/search/widgets/WidgetHistogram.js
index bf61008469..f14a8a36bb 100644
--- a/gui/src/components/search/widgets/WidgetHistogram.js
+++ b/gui/src/components/search/widgets/WidgetHistogram.js
@@ -45,6 +45,7 @@ export const WidgetHistogram = React.memo((
   const setWidget = useSetWidget(id)
 
   // Create final axis config for the plot
+  // console.log(x)
   const xAxis = useMemo(() => getAxisConfig(x, filterData, units), [x, filterData, units])
 
   const handleEdit = useCallback(() => {
diff --git a/gui/src/components/search/widgets/WidgetScatterPlot.js b/gui/src/components/search/widgets/WidgetScatterPlot.js
index 2e403e6f85..c8bf11c6b7 100644
--- a/gui/src/components/search/widgets/WidgetScatterPlot.js
+++ b/gui/src/components/search/widgets/WidgetScatterPlot.js
@@ -107,8 +107,8 @@ export const WidgetScatterPlot = React.memo((
   // Get storage unit for API communication
   const {storageUnitX, storageUnitY, storageUnitColor} = useMemo(() => {
     if (error) return {}
-    const storageUnitX = new Unit(filterData[xParsed.quantity].unit || 'dimensionless')
-    const storageUnitY = new Unit(filterData[yParsed.quantity].unit || 'dimensionless')
+    const storageUnitX = new Unit(filterData[xParsed.quantity]?.unit || 'dimensionless')
+    const storageUnitY = new Unit(filterData[yParsed.quantity]?.unit || 'dimensionless')
     const storageUnitColor = new Unit(filterData[colorParsed?.quantity]?.unit || 'dimensionless')
     return {storageUnitX, storageUnitY, storageUnitColor}
   }, [filterData, xParsed.quantity, yParsed.quantity, colorParsed?.quantity, error])
diff --git a/gui/src/components/search/widgets/WidgetScatterPlotEdit.js b/gui/src/components/search/widgets/WidgetScatterPlotEdit.js
index 05f8fe9a1f..e0c7dad5f0 100644
--- a/gui/src/components/search/widgets/WidgetScatterPlotEdit.js
+++ b/gui/src/components/search/widgets/WidgetScatterPlotEdit.js
@@ -93,7 +93,7 @@ export const WidgetScatterPlotEdit = React.memo(({widget}) => {
     const handleAcceptQuantity = useCallback((key, value) => {
       handleAccept(key, value)
       const { quantity } = parseJMESPath(value)
-      const dimension = filterData[quantity]?.dimension
+      const dimension = filterData[quantity]?.dimension || 'dimensionless'
       setDimensions((old) => ({...old, [key]: dimension}))
     }, [handleAccept, filterData])
 
diff --git a/gui/src/components/search/widgets/WidgetTerms.js b/gui/src/components/search/widgets/WidgetTerms.js
index e110b34791..b2b550e199 100644
--- a/gui/src/components/search/widgets/WidgetTerms.js
+++ b/gui/src/components/search/widgets/WidgetTerms.js
@@ -15,7 +15,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import React, { useState, useCallback, useMemo } from 'react'
+import React, { useState, useCallback, useMemo, useEffect } from 'react'
 import PropTypes from 'prop-types'
 import { string, bool } from 'yup'
 import clsx from 'clsx'
@@ -108,12 +108,18 @@ export const WidgetTerms = React.memo((
   className,
   'data-testid': testID
 }) => {
-  const {useAgg, useFilterState, filterData} = useSearchContext()
+  const {useAgg, useRemoveAgg, useFilterState, filterData} = useSearchContext()
   const styles = useStyles()
   const [filter, setFilter] = useFilterState(search_quantity)
   const { height, ref } = useResizeDetector()
   const { useSetWidget } = useSearchContext()
   const setWidget = useSetWidget(id)
+  const removeAgg = useRemoveAgg()
+
+  // When component is unmounted, remove aggregation request
+  useEffect(() => {
+    return () => removeAgg(search_quantity, id)
+  }, [removeAgg, search_quantity, id])
 
   // The terms aggregations need to request at least 1 item or an API error is thrown
   const aggSize = useMemo(() => Math.max(Math.floor(height / inputItemHeight), 1), [height])
diff --git a/gui/src/utils.js b/gui/src/utils.js
index d3fc8e7644..c9c198a68a 100644
--- a/gui/src/utils.js
+++ b/gui/src/utils.js
@@ -614,7 +614,16 @@ export function getSerializer(dtype, pretty = true) {
   } else if (dtype === DType.Boolean) {
     return (value) => value ? 'true' : 'false'
   } else {
-    return (value) => value
+    return (value) => {
+      if (isNil(value)) {
+        return value
+      }
+      let val = value instanceof Quantity ? value.normalized_value : value
+      if (isNumber(val) && pretty) {
+        val = formatNumber(val)
+      }
+      return val
+    }
   }
 }
 
@@ -1546,6 +1555,15 @@ export function parseQuantityName(fullName) {
   return {path, schema: schemaNew, dtype}
 }
 
+/**
+ * Autogenerates a sensible default name for a quantity based on its path.
+ */
+export function getLabel(quantity) {
+  if (isNil(quantity)) return ''
+  const {path} = parseQuantityName(quantity)
+  return capitalize(split(path, '.').slice(-1)[0].replace(/_/g, ' '))
+}
+
 /**
  * Used to parse a possible query operator from a quantity name.
  */
diff --git a/nomad/metainfo/elasticsearch_extension.py b/nomad/metainfo/elasticsearch_extension.py
index f3a3538200..6c44725683 100644
--- a/nomad/metainfo/elasticsearch_extension.py
+++ b/nomad/metainfo/elasticsearch_extension.py
@@ -198,7 +198,7 @@ if TYPE_CHECKING:
 schema_separator = '#'
 dtype_separator = '#'
 yaml_prefix = 'entry_id:'
-nexus_prefix = 'pynxtools.nomad.schema.NeXus.'
+nexus_prefix = 'pynxtools.nomad.schema'
 
 
 class DocumentType:
@@ -488,8 +488,6 @@ class DocumentType:
         if self != entry_type:
             return None
 
-        logger = utils.get_logger(__name__, doc_type=self.name)
-
         # Remove existing dynamic quantities
         for name, quantity in list(self.quantities.items()):
             if quantity.dynamic:
@@ -556,7 +554,7 @@ class DocumentType:
                     section.section_cls, EntryData
                 ):
                     schema_name = section.qualified_name()
-                    if section.name == 'NeXus':
+                    if schema_name.startswith('pynxtools'):
                         # Allow App searches for specific AppDefs:
                         selected_path = [
                             'Root',
@@ -569,7 +567,11 @@ class DocumentType:
                             'Ellipsometry',
                             'Raman',
                         ]
-                        max_level = 3
+                        if section.name in selected_path:
+                            max_level = 3
+                        else:
+                            max_level = 0
+                        selected_path = ['']
                     else:
                         selected_path = ['']
                         max_level = -1
@@ -596,14 +598,6 @@ class DocumentType:
                                 annotation, qualified_name=full_name, repeats=repeats
                             )
                             quantities_dynamic[full_name] = search_quantity
-                        # print(
-                        #     'With '
-                        #     + section.name
-                        #     + '.'
-                        #     + path_prefix
-                        #     + ' the number of dynamic quantities: '
-                        #     + str(len(quantities_dynamic))
-                        # )
         self.quantities.update(quantities_dynamic)
 
     def _register(self, annotation, prefix, repeats):
diff --git a/nomad/metainfo/metainfo.py b/nomad/metainfo/metainfo.py
index 3c155c8d6a..d047228ea5 100644
--- a/nomad/metainfo/metainfo.py
+++ b/nomad/metainfo/metainfo.py
@@ -27,13 +27,9 @@ import warnings
 from collections.abc import Iterable
 from copy import deepcopy
 from functools import wraps
-from typing import (
-    Any,
-    TypeVar,
-    cast,
-    Literal,
-)
-from collections.abc import Callable as TypingCallable
+from typing import Any
+from typing import Callable as TypingCallable
+from typing import Literal, TypeVar, cast
 from urllib.parse import urlsplit, urlunsplit
 
 import docstring_parser
@@ -43,26 +39,20 @@ from pydantic import TypeAdapter, ValidationError
 from typing_extensions import deprecated  # type: ignore
 
 from nomad.config import config
-from nomad.metainfo.data_type import (
-    Datatype,
-    normalize_type,
-    Number,
-    m_str,
-    Enum,
-    Datetime as DatetimeType,
-    Unit as UnitType,
-    Capitalized as CapitalizedType,
-    JSON as JSONType,
-    Bytes as BytesType,
-    Callable as CallableType,
-    URL as URLType,
-    Dimension as DimensionType,
-    File as FileType,
-    Any as AnyType,
-    check_dimensionality,
-    ExactNumber,
-    InexactNumber,
-)
+from nomad.metainfo.data_type import JSON as JSONType
+from nomad.metainfo.data_type import URL as URLType
+from nomad.metainfo.data_type import Any as AnyType
+from nomad.metainfo.data_type import Bytes as BytesType
+from nomad.metainfo.data_type import Callable as CallableType
+from nomad.metainfo.data_type import Capitalized as CapitalizedType
+from nomad.metainfo.data_type import Datatype
+from nomad.metainfo.data_type import Datetime as DatetimeType
+from nomad.metainfo.data_type import Dimension as DimensionType
+from nomad.metainfo.data_type import Enum, ExactNumber
+from nomad.metainfo.data_type import File as FileType
+from nomad.metainfo.data_type import InexactNumber, Number
+from nomad.metainfo.data_type import Unit as UnitType
+from nomad.metainfo.data_type import check_dimensionality, m_str, normalize_type
 from nomad.metainfo.util import (
     MQuantity,
     MSubSectionList,
@@ -74,11 +64,14 @@ from nomad.metainfo.util import (
     to_dict,
 )
 from nomad.units import ureg as units
+from pydantic import ValidationError, parse_obj_as
+from typing_extensions import deprecated  # type: ignore
+
 from .annotation import (
     Annotation,
+    AnnotationModel,
     DefinitionAnnotation,
     SectionAnnotation,
-    AnnotationModel,
 )
 
 # todo: remove once simulation package does not use it anymore
@@ -1927,7 +1920,7 @@ class MSection(metaclass=MObjectMeta):
                     target_value = quantity.default
 
             def _transform_wrapper(_value, _stack=None):
-                _path = path
+                _path = path if path else ''
                 if _stack is not None:
                     _path += '/' + '/'.join(str(i) for i in _stack)
                 return (
diff --git a/nomad/search.py b/nomad/search.py
index 329bfe1a09..eb34d65764 100644
--- a/nomad/search.py
+++ b/nomad/search.py
@@ -514,7 +514,7 @@ def _es_to_entry_dict(
                 quantity = doc_type.quantities.get(id)
                 if not quantity:
                     path, schema, _ = parse_quantity_name(id)
-                    if schema and schema.startswith(yaml_prefix):
+                    if schema and schema.startswith((yaml_prefix, nexus_prefix)):
                         dtype_map = {
                             'float_value': float,
                             'str_value': str,
@@ -530,9 +530,6 @@ def _es_to_entry_dict(
                         quantity = get_quantity(
                             Quantity(type=dtype), path, schema, doc_type
                         )
-                    elif path.startswith(nexus_prefix):
-                        definition = get_definition(path)
-                        quantity = get_quantity(definition, path, schema, doc_type)
                     else:
                         continue
                 value_field_name = get_searchable_quantity_value_field(
@@ -699,9 +696,9 @@ def validate_quantity(
 
     if quantity is None:
         path, schema, dtype = parse_quantity_name(quantity_name)
-        # Queries targeting YAML are translated into dynamic quantity searches
-        # on the fly using the provided data type.
-        if schema and schema.startswith(yaml_prefix):
+        # Queries targeting YAML or nexus schema are translated into dynamic
+        # quantity searches on the fly using the provided data type.
+        if schema and schema.startswith((yaml_prefix, nexus_prefix)):
             datatype = {
                 'int': int,
                 'str': str,
@@ -713,23 +710,12 @@ def validate_quantity(
                 raise QueryValidationError(
                     (
                         f'Could not resolve the data type for quantity {quantity_name}. '
-                        'Please include the data type in the quantity name for quantities '
-                        'that target a custom YAML schema.'
+                        'Please include the data type in the quantity name using the '
+                        '"#<type>" postfix.'
                     ),
                     loc=[quantity_name] if loc is None else loc,
                 )
             quantity = get_quantity(Quantity(type=datatype), path, schema, doc_type)
-        # Queries targeting nexus are translated into dynamic quantity searches
-        # on the fly by looking at the definition in the metainfo.
-        elif path.startswith(nexus_prefix):
-            try:
-                definition = get_definition(path)
-                quantity = get_quantity(definition, path, schema, doc_type)
-            except Exception as e:
-                raise QueryValidationError(
-                    f'Could not find the definition for "{path}" in the metainfo.',
-                    loc=[quantity_name] if loc is None else loc,
-                ) from e
         else:
             raise QueryValidationError(
                 f'{quantity_name} is not a {doc_type} quantity',
diff --git a/tests/fixtures/data.py b/tests/fixtures/data.py
index 350fa8e6d7..190bdde295 100644
--- a/tests/fixtures/data.py
+++ b/tests/fixtures/data.py
@@ -488,44 +488,6 @@ def example_data_schema_python(
     data.delete()
 
 
-@pytest.fixture(scope='function')
-def example_data_nexus(
-    elastic_module, raw_files_module, mongo_module, user1, normalized
-):
-    """
-    Contains entries that store data using a python schema.
-    """
-    data = ExampleData(main_author=user1)
-    upload_id = 'id_nexus_published'
-
-    data.create_upload(upload_id=upload_id, upload_name=upload_id, published=True)
-    data.create_entry(
-        upload_id=upload_id,
-        entry_id=f'test_entry_nexus',
-        mainfile=f'test_content/test.archive.json',
-        search_quantities=[
-            SearchableQuantity(
-                id=f'nexus.NXiv_temp.ENTRY.DATA.temperature__field',
-                definition='nexus.NXiv_temp.ENTRY.DATA.temperature__field',
-                path_archive='nexus.NXiv_temp.ENTRY.0.DATA.0.temperature__field',
-                float_value=273.15,
-            ),
-            SearchableQuantity(
-                id=f'nexus.NXiv_temp.ENTRY.definition__field',
-                definition='nexus.NXiv_temp.NXentry.definition__field',
-                path_archive='nexus.NXiv_temp.ENTRY.0.definition__field',
-                str_value='NXiv_temp',
-            ),
-        ],
-    )
-    data.save(with_files=False)
-
-    yield
-
-    # The data is deleted after fixture goes out of scope
-    data.delete()
-
-
 @pytest.fixture(scope='function')
 def example_data_schema_yaml(
     elastic_module,
-- 
GitLab