Skip to content

Commit be9b49e

Browse files
committed
Improve support for NetCDF fill value and valid range attributes
1 parent 7f3203a commit be9b49e

File tree

12 files changed

+279
-62
lines changed

12 files changed

+279
-62
lines changed

cypress/e2e/app.cy.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,21 +123,21 @@ describe('/mock', () => {
123123
}
124124
});
125125

126-
it('visualize datasets with fill value', () => {
127-
cy.selectExplorerNode('nD_datasets');
126+
it('visualize dataset with fill value', () => {
127+
cy.selectExplorerNode('netcdf');
128128

129-
cy.selectExplorerNode('oneD_fillvalue');
130-
cy.findByRole('figure', { name: 'oneD_fillvalue' }).should('be.visible');
129+
cy.selectExplorerNode('_FillValue');
130+
cy.findByRole('figure', { name: '_FillValue' }).should('be.visible');
131131

132132
if (Cypress.env('TAKE_SNAPSHOTS')) {
133-
cy.matchImageSnapshot('fillvalue_1D');
133+
cy.matchImageSnapshot('fillvalue_2D');
134134
}
135135

136-
cy.selectExplorerNode('twoD_fillvalue');
137-
cy.findByRole('figure', { name: 'twoD_fillvalue' }).should('be.visible');
136+
cy.selectVisTab('Line');
137+
cy.waitForStableDOM();
138138

139139
if (Cypress.env('TAKE_SNAPSHOTS')) {
140-
cy.matchImageSnapshot('fillvalue_2D');
140+
cy.matchImageSnapshot('fillvalue_1D');
141141
}
142142
});
143143

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { screen, within } from '@testing-library/react';
2+
import { expect, test } from 'vitest';
3+
4+
import { renderApp } from '../test-utils';
5+
6+
test('visualize dataset with `valid_min` and/or `valid_max` attributes', async () => {
7+
const { selectExplorerNode } = await renderApp('/netcdf/valid_min');
8+
9+
const fig1 = screen.getByRole('figure', { name: 'valid_min' });
10+
expect(within(fig1).getByText('4e+2')).toBeVisible(); // data max
11+
expect(within(fig1).getByText('5e+0')).toBeVisible(); // valid_min
12+
13+
await selectExplorerNode('valid_max');
14+
const fig2 = screen.getByRole('figure', { name: 'valid_max' });
15+
expect(within(fig2).getByText('2e+2')).toBeVisible(); // valid_max
16+
expect(within(fig2).getByText('−9.5e+1')).toBeVisible(); // data min
17+
18+
await selectExplorerNode('valid_min_max');
19+
const fig3 = screen.getByRole('figure', { name: 'valid_min_max' });
20+
expect(within(fig3).getByText('2e+2')).toBeVisible(); // valid_max
21+
expect(within(fig3).getByText('5e+0')).toBeVisible(); // valid_min
22+
});
23+
24+
test('visualize dataset with `valid_range` attribute', async () => {
25+
await renderApp('/netcdf/valid_range');
26+
27+
const fig = screen.getByRole('figure', { name: 'valid_range' });
28+
expect(within(fig).getByText('2e+2')).toBeVisible(); // valid_range[1]
29+
expect(within(fig).getByText('5e+0')).toBeVisible(); // valid_range[0]
30+
});
31+
32+
test('visualize dataset with `_FillValue` attribute', async () => {
33+
const { selectExplorerNode } = await renderApp('/netcdf/_FillValue');
34+
35+
const fig1 = screen.getByRole('figure', { name: '_FillValue' });
36+
expect(within(fig1).getByText('9.9e+1')).toBeVisible(); // closest data value lower than _FillValue
37+
expect(within(fig1).getByText('−9.5e+1')).toBeVisible(); // data min
38+
39+
await selectExplorerNode('_FillValue (negative)', true);
40+
const fig2 = screen.getByRole('figure', { name: '_FillValue (negative)' });
41+
expect(within(fig2).getByText('4e+2')).toBeVisible(); // data max
42+
expect(within(fig2).getByText('−6e+0')).toBeVisible(); // closest data value greater than _FillValue
43+
});

packages/app/src/providers/mock/mock-file.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { H5T_CSET, H5T_STR } from '@h5web/shared/h5t';
22
import { type GroupWithChildren } from '@h5web/shared/hdf5-models';
33
import {
4+
arrayShape,
45
arrayType,
56
boolType,
67
compoundType,
@@ -116,10 +117,6 @@ export function makeMockFile(): GroupWithChildren {
116117
group('nD_datasets', [
117118
array('oneD_linear'),
118119
array('oneD'),
119-
array('oneD_fillvalue', {
120-
valueId: 'oneD',
121-
attributes: [scalar('_FillValue', 400)],
122-
}),
123120
array('oneD_bigint'),
124121
array('oneD_cplx'),
125122
array('oneD_compound', {
@@ -136,10 +133,6 @@ export function makeMockFile(): GroupWithChildren {
136133
type: enumType(intType(false, 8), ENUM_MAPPING),
137134
}),
138135
array('twoD'),
139-
array('twoD_fillvalue', {
140-
valueId: 'twoD',
141-
attributes: [scalar('_FillValue', 400)],
142-
}),
143136
array('twoD_neg'),
144137
array('twoD_bigint'),
145138
array('twoD_cplx'),
@@ -435,6 +428,34 @@ export function makeMockFile(): GroupWithChildren {
435428
],
436429
}),
437430
]),
431+
group('netcdf', [
432+
array('valid_min', {
433+
valueId: 'twoD',
434+
attributes: [scalar('valid_min', 5)],
435+
}),
436+
array('valid_max', {
437+
valueId: 'twoD',
438+
attributes: [scalar('valid_max', 200)],
439+
}),
440+
array('valid_min_max', {
441+
valueId: 'twoD',
442+
attributes: [scalar('valid_min', 5), scalar('valid_max', 200)],
443+
}),
444+
array('valid_range', {
445+
valueId: 'twoD',
446+
attributes: [
447+
dataset('valid_range', arrayShape([2]), floatType(), [5, 200]),
448+
],
449+
}),
450+
array('_FillValue', {
451+
valueId: 'twoD',
452+
attributes: [scalar('_FillValue', 100)],
453+
}),
454+
array('_FillValue (negative)', {
455+
valueId: 'twoD',
456+
attributes: [scalar('_FillValue', -9)],
457+
}),
458+
]),
438459
group('resilience', [
439460
scalar('error_value', 0, { attributes: [scalarAttr('attr', 1)] }),
440461
scalar('slow_value', 42),

packages/app/src/test-utils.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { type NxDataVis } from './vis-packs/nexus/visualizations';
1515

1616
interface RenderAppResult extends RenderResult {
1717
user: ReturnType<typeof userEvent.setup>;
18-
selectExplorerNode: (name: string) => Promise<void>;
18+
selectExplorerNode: (name: string, exact?: boolean) => Promise<void>;
1919
selectVisTab: (name: Vis | NxDataVis) => Promise<void>;
2020
}
2121

@@ -70,9 +70,11 @@ export async function renderApp(
7070
user,
7171
...renderResult,
7272

73-
selectExplorerNode: async (name) => {
73+
selectExplorerNode: async (name, exact = false) => {
7474
const item = await screen.findByRole('treeitem', {
75-
name: new RegExp(String.raw`^${name}(?: \(NeXus group\))?$`, 'u'), // account for potential NeXus badge
75+
name: exact
76+
? name
77+
: new RegExp(String.raw`^${name}(?: \(NeXus group\))?$`, 'u'), // account for potential NeXus badge
7678
});
7779

7880
await user.click(item);

packages/app/src/vis-packs/core/heatmap/HeatmapVisContainer.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import { useDimMappingState } from '../../../dim-mapping-store';
1010
import { useValuesInCache } from '../../../hooks';
1111
import visualizerStyles from '../../../visualizer/Visualizer.module.css';
1212
import { type VisContainerProps } from '../../models';
13+
import { useNcIgnoreValue } from '../../netcdf/hooks';
1314
import VisBoundary from '../../VisBoundary';
14-
import { useIgnoreFillValue } from '../hooks';
1515
import ValueFetcher from '../ValueFetcher';
1616
import { useHeatmapConfig } from './config';
1717
import MappedHeatmapVis from './MappedHeatmapVis';
@@ -30,9 +30,8 @@ function HeatmapVisContainer(props: VisContainerProps) {
3030
});
3131

3232
const config = useHeatmapConfig();
33-
3433
const selection = getSliceSelection(dimMapping);
35-
const ignoreValue = useIgnoreFillValue(entity);
34+
const ignoreValue = useNcIgnoreValue(entity);
3635

3736
return (
3837
<>

packages/app/src/vis-packs/core/hooks.ts

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,13 @@ import {
1010
type BuiltInExporter,
1111
type ExportEntry,
1212
type ExportFormat,
13-
type IgnoreValue,
1413
type NumArray,
1514
} from '@h5web/shared/vis-models';
1615
import { useToggle } from '@react-hookz/web';
1716
import { type NdArray } from 'ndarray';
1817
import { useCallback, useMemo } from 'react';
1918

2019
import { useDataContext } from '../../providers/DataProvider';
21-
import {
22-
bigIntTypedArrayFromDType,
23-
typedArrayFromDType,
24-
} from '../../providers/utils';
25-
import { findScalarNumAttr, getAttributeValue } from '../../utils';
2620
import { applyMapping, getBaseArray, toNumArray } from './utils';
2721

2822
export const useToNumArray = createMemo(toNumArray);
@@ -97,38 +91,6 @@ export function useMappedArrays(
9791
);
9892
}
9993

100-
export function useIgnoreFillValue(dataset: Dataset): IgnoreValue | undefined {
101-
const { attrValuesStore } = useDataContext();
102-
103-
return useMemo(() => {
104-
const fillValueAttr = findScalarNumAttr(dataset, '_FillValue');
105-
if (!fillValueAttr) {
106-
return undefined;
107-
}
108-
109-
const rawFillValue = getAttributeValue(
110-
dataset,
111-
fillValueAttr,
112-
attrValuesStore,
113-
);
114-
115-
const DTypedArray = bigIntTypedArrayFromDType(dataset.type)
116-
? Float64Array // matches `useToNumArray` logic
117-
: typedArrayFromDType(dataset.type);
118-
119-
// Cast fillValue in the type of the dataset values to be able to use `===` for the comparison
120-
const fillValue = DTypedArray
121-
? new DTypedArray([Number(rawFillValue)])[0]
122-
: undefined;
123-
124-
if (fillValue === undefined) {
125-
return undefined;
126-
}
127-
128-
return (val) => val === fillValue;
129-
}, [dataset, attrValuesStore]);
130-
}
131-
13294
export function useExportEntries<F extends ExportFormat[]>(
13395
formats: F,
13496
dataset: Dataset,

packages/app/src/vis-packs/core/line/LineVisContainer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { useDimMappingState } from '../../../dim-mapping-store';
99
import { useValuesInCache } from '../../../hooks';
1010
import visualizerStyles from '../../../visualizer/Visualizer.module.css';
1111
import { type VisContainerProps } from '../../models';
12+
import { useNcIgnoreValue } from '../../netcdf/hooks';
1213
import VisBoundary from '../../VisBoundary';
13-
import { useIgnoreFillValue } from '../hooks';
1414
import ValueFetcher from '../ValueFetcher';
1515
import { useLineConfig } from './config';
1616
import MappedLineVis from './MappedLineVis';
@@ -28,8 +28,8 @@ function LineVisContainer(props: VisContainerProps) {
2828
});
2929

3030
const config = useLineConfig();
31-
const ignoreValue = useIgnoreFillValue(entity);
3231
const selection = getSliceSelection(dimMapping);
32+
const ignoreValue = useNcIgnoreValue(entity);
3333

3434
return (
3535
<>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { type IgnoreValue } from '@h5web/lib';
2+
import { hasNumericType } from '@h5web/shared/guards';
3+
import { type ArrayShape, type Dataset } from '@h5web/shared/hdf5-models';
4+
import { useMemo } from 'react';
5+
6+
import { useDataContext } from '../../providers/DataProvider';
7+
import { createIgnoreFillValue, getFillValue, getValidRange } from './utils';
8+
9+
/* Priority order: `valid_min` and/or `valid_max`, then `valid_range`, then `_FillValue`.
10+
* Note that `_FillValue` acts as an invalid upper or lower bound (if positive or negative respectively).
11+
* See NetCDF `valid_range` convention: https://docs.unidata.ucar.edu/netcdf-c/current/attribute_conventions.html */
12+
export function useNcIgnoreValue(
13+
dataset: Dataset<ArrayShape>,
14+
): IgnoreValue | undefined {
15+
const { attrValuesStore } = useDataContext();
16+
17+
return useMemo(() => {
18+
if (!hasNumericType(dataset)) {
19+
return undefined;
20+
}
21+
22+
const validRange = getValidRange(dataset, attrValuesStore);
23+
if (validRange) {
24+
const [validMin, validMax] = validRange;
25+
return (val) => val < validMin || val > validMax;
26+
}
27+
28+
const fillValue = getFillValue(dataset, attrValuesStore);
29+
if (fillValue !== undefined) {
30+
return createIgnoreFillValue(fillValue, dataset.type);
31+
}
32+
33+
return undefined;
34+
}, [dataset, attrValuesStore]);
35+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { floatType, intType } from '@h5web/shared/hdf5-utils';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { createIgnoreFillValue } from './utils';
5+
6+
describe('createIgnoreFillValue', () => {
7+
describe('with integers', () => {
8+
it('should ignore values greater or equal to positive fill value', () => {
9+
const ignoreValue = createIgnoreFillValue(10, intType());
10+
expect(ignoreValue(9)).toBe(false);
11+
expect(ignoreValue(10)).toBe(true);
12+
expect(ignoreValue(11)).toBe(true);
13+
});
14+
15+
it('should ignore values lower or equal to negative fill value', () => {
16+
const ignoreValue = createIgnoreFillValue(-10, intType());
17+
expect(ignoreValue(-9)).toBe(false);
18+
expect(ignoreValue(-10)).toBe(true);
19+
expect(ignoreValue(-11)).toBe(true);
20+
});
21+
});
22+
23+
describe('with floats', () => {
24+
it('should ignore values greater than positive fill value', () => {
25+
const ignoreValue = createIgnoreFillValue(0.3, floatType(64));
26+
expect(ignoreValue(0.2)).toBe(false);
27+
expect(ignoreValue(0.3005)).toBe(true);
28+
});
29+
30+
it('should ignore values close-enough to positive fill value', () => {
31+
const ignoreValue = createIgnoreFillValue(0.3, floatType(64));
32+
expect(ignoreValue(0.3 - 2 * Number.EPSILON)).toBe(false);
33+
expect(ignoreValue(0.3 - Number.EPSILON)).toBe(true);
34+
expect(ignoreValue(0.3)).toBe(true);
35+
expect(ignoreValue(0.3 + Number.EPSILON)).toBe(true);
36+
expect(ignoreValue(0.3 + 2 * Number.EPSILON)).toBe(true);
37+
});
38+
39+
it('should ignore values lower than negative fill value', () => {
40+
const ignoreValue = createIgnoreFillValue(-0.3, floatType(64));
41+
expect(ignoreValue(-0.2)).toBe(false);
42+
expect(ignoreValue(-0.3005)).toBe(true);
43+
});
44+
45+
it('should ignore values close-enough to negative fill value', () => {
46+
const ignoreValue = createIgnoreFillValue(-0.3, floatType(64));
47+
expect(ignoreValue(-0.3 + 2 * Number.EPSILON)).toBe(false);
48+
expect(ignoreValue(-0.3 + Number.EPSILON)).toBe(true);
49+
expect(ignoreValue(-0.3)).toBe(true);
50+
expect(ignoreValue(-0.3 - Number.EPSILON)).toBe(true);
51+
expect(ignoreValue(-0.3 - 2 * Number.EPSILON)).toBe(true);
52+
});
53+
});
54+
});

0 commit comments

Comments
 (0)