Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ML] Add anomaly marker to charts when gap exists in data #29628

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -22,5 +22,10 @@ export const chartData = [
date: new Date('2017-02-23T13:00:00.000Z'),
value: 201039318, anomalyScore: 59.83488, numberOfCauses: 1,
actual: [201039318], typical: [132739.5267403542]
},
{
date: new Date('2017-02-23T14:00:00.000Z'),
value: null, anomalyScore: 98.56166,
actual: [201039318], typical: [132739.5267403542]
}
];
Expand Up @@ -267,11 +267,13 @@ export const ExplorerChartSingleMetric = injectI18n(class ExplorerChartSingleMet
function drawLineChartMarkers(data) {
// Render circle markers for the points.
// These are used for displaying tooltips on mouseover.
// Don't render dots where value=null (data gaps) or for multi-bucket anomalies.
// Don't render dots where value=null (data gaps, with no anomalies)
// or for multi-bucket anomalies.
const dots = lineChartGroup.append('g')
.attr('class', 'chart-markers')
.selectAll('.metric-value')
.data(data.filter(d => (d.value !== null && !showMultiBucketAnomalyMarker(d))));
.data(data.filter(d => ((d.value !== null || typeof d.anomalyScore === 'number') &&
!showMultiBucketAnomalyMarker(d))));

// Remove dots that are no longer needed i.e. if number of chart points has decreased.
dots.exit().remove();
Expand Down
Expand Up @@ -135,8 +135,8 @@ describe('ExplorerChart', () => {
expect(dots[0].getAttribute('r')).toBe('1.5');

const chartMarkers = wrapper.getDOMNode().querySelector('.chart-markers').querySelectorAll('circle');
expect([...chartMarkers]).toHaveLength(3);
expect([...chartMarkers].map(d => +d.getAttribute('r'))).toEqual([7, 7, 7]);
expect([...chartMarkers]).toHaveLength(4);
expect([...chartMarkers].map(d => +d.getAttribute('r'))).toEqual([7, 7, 7, 7]);
});

it('Anomaly Explorer Chart with single data point', () => {
Expand Down
Expand Up @@ -677,10 +677,20 @@ export const TimeseriesChart = injectI18n(class TimeseriesChart extends React.Co
}

yMin = d3.min(combinedData, (d) => {
return d.lower !== undefined ? Math.min(d.value, d.lower) : d.value;
let metricValue = d.value;
if (metricValue === null && d.anomalyScore !== undefined && d.actual !== undefined) {
// If an anomaly coincides with a gap in the data, use the anomaly actual value.
metricValue = Array.isArray(d.actual) ? d.actual[0] : d.actual;
}
return d.lower !== undefined ? Math.min(metricValue, d.lower) : metricValue;
});
yMax = d3.max(combinedData, (d) => {
return d.upper !== undefined ? Math.max(d.value, d.upper) : d.value;
let metricValue = d.value;
if (metricValue === null && d.anomalyScore !== undefined && d.actual !== undefined) {
// If an anomaly coincides with a gap in the data, use the anomaly actual value.
metricValue = Array.isArray(d.actual) ? d.actual[0] : d.actual;
}
return d.upper !== undefined ? Math.max(metricValue, d.upper) : metricValue;
});

if (yMax === yMin) {
Expand All @@ -702,9 +712,10 @@ export const TimeseriesChart = injectI18n(class TimeseriesChart extends React.Co
if (mlAnnotationsEnabled && focusAnnotationData && focusAnnotationData.length > 0) {
const levels = getAnnotationLevels(focusAnnotationData);
const maxLevel = d3.max(Object.keys(levels).map(key => levels[key]));
// TODO needs revisting to be a more robust normalization
// TODO needs revisiting to be a more robust normalization
yMax = yMax * (1 + (maxLevel + 1) / 5);
}

this.focusYScale.domain([yMin, yMax]);

} else {
Expand Down Expand Up @@ -758,9 +769,11 @@ export const TimeseriesChart = injectI18n(class TimeseriesChart extends React.Co

// Render circle markers for the points.
// These are used for displaying tooltips on mouseover.
// Don't render dots where value=null (data gaps) or for multi-bucket anomalies.
// Don't render dots where value=null (data gaps, with no anomalies)
// or for multi-bucket anomalies.
const dots = d3.select('.focus-chart-markers').selectAll('.metric-value')
.data(data.filter(d => (d.value !== null && !showMultiBucketAnomalyMarker(d))));
.data(data.filter(d => ((d.value !== null || typeof d.anomalyScore === 'number') &&
!showMultiBucketAnomalyMarker(d))));

// Remove dots that are no longer needed i.e. if number of chart points has decreased.
dots.exit().remove();
Expand Down
20 changes: 20 additions & 0 deletions x-pack/plugins/ml/public/util/__tests__/chart_utils.js
Expand Up @@ -71,6 +71,26 @@ describe('ML - chart utils', () => {
expect(limits.max).to.be(105);
});

it('returns minimum of 0 when data includes an anomaly for missing data', () => {
const data = [
{ date: new Date('2017-02-23T09:00:00.000Z'), value: 22.2 },
{ date: new Date('2017-02-23T10:00:00.000Z'), value: 23.3 },
{ date: new Date('2017-02-23T11:00:00.000Z'), value: 24.4 },
{
date: new Date('2017-02-23T12:00:00.000Z'),
value: null, anomalyScore: 97.32085,
actual: [0], typical: [22.2]
},
{ date: new Date('2017-02-23T13:00:00.000Z'), value: 21.3 },
{ date: new Date('2017-02-23T14:00:00.000Z'), value: 21.2 },
{ date: new Date('2017-02-23T15:00:00.000Z'), value: 21.1 }
];

const limits = chartLimits(data);
expect(limits.min).to.be(0);
expect(limits.max).to.be(24.4);
});

});

describe('filterAxisLabels', () => {
Expand Down
12 changes: 9 additions & 3 deletions x-pack/plugins/ml/public/util/chart_utils.js
Expand Up @@ -24,10 +24,16 @@ export const SCHEDULED_EVENT_SYMBOL_HEIGHT = 5;
const MAX_LABEL_WIDTH = 100;

export function chartLimits(data = []) {
const limits = { max: 0, min: 0 };
const domain = d3.extent(data, (d) => {
let metricValue = d.value;
if (metricValue === null && d.anomalyScore !== undefined && d.actual !== undefined) {
// If an anomaly coincides with a gap in the data, use the anomaly actual value.
metricValue = Array.isArray(d.actual) ? d.actual[0] : d.actual;
}
return metricValue;
});
const limits = { max: domain[1], min: domain[0] };

limits.max = d3.max(data, (d) => d.value);
limits.min = d3.min(data, (d) => d.value);
if (limits.max === limits.min) {
limits.max = d3.max(data, (d) => {
if (d.typical) {
Expand Down