0

I'm trying to run a Mann-Kendall analysis on spatial NDVI timeseries for a number of sites. As part of the analyses, I have been using DynamicWorld to mask out undesirable areas (e.g. snow and ice, bare ground, water, etc.). I was initially following a GEE forum tutorial for the MK analysis, but myself and others have found issues with the way the sum of the signs were calculated. I tried to come up with a solution myself:

// Function to calculate sign between two images
// If NDVI at time j is more than NDVI at time i, the sign is 1
    // if opposite is true, sign is -1
    // if equal, sign is 0
var sign = function(i, j) {
  var diff = ee.Image(j).subtract(i);
  // Applying DynamicWorld masking to starting image
  var zero = ee.Image(0).updateMask(classmask.not());
  return zero
    .where(diff.gt(0), 1)
    .where(diff.lt(0), -1)
    .int();
};

But this has created issues where pixels previously masked by DynamicWorld are exported as 0 values within the CSV, despite appearing as masked when added to the map before export. I've tried a number of solutions, including stating the mask again before export or setting as nodata values in export, but with no luck. This seems to be an issue specifically with Mann-Kendall S statistic calculation, because there is no pixel inflation in later stages when exporting p values or Sen's Slope.

Full code below.

// Load site geometry
var allsites = ee.FeatureCollection('users/USERNAME/allsites');

// Select one site (example)
var siteId = 'Border_Meuse';
var site = allsites.filter(ee.Filter.eq('site_id', siteId));
var geometry = site.geometry().dissolve();

///////////////////////
//                   //
//    DynamicWorld   //
//                   //
///////////////////////

var dworld = ee.ImageCollection("GOOGLE/DYNAMICWORLD/V1")
.filterDate('2025-08-06', '2025-08-06')
.filterBounds(site);

var allclass = ['water','trees','grass','flooded_vegetation','crops','shrub_and_scrub',
  'built','bare','snow_and_ice'];
var undesirableclass = ['water', 'crops', 'built', 'bare', 'snow_and_ice'];

var siteCompositor = function(imageCollection) {
  return imageCollection
    .select(allclass)
    .median()
    .copyProperties(imageCollection.first(), imageCollection.first().propertyNames());
};

var dworldcomp = ee.Image(siteCompositor(dworld)).clip(site.geometry());

var classprob = dworldcomp.reduce(ee.Reducer.sum());

var dworldsites = dworldcomp.divide(classprob);

// Sum the probabilities of the undesirable classes
var classprobsum = dworldsites.select(undesirableclass).reduce(ee.Reducer.sum());

// Mask where the total probability > 0.75
var classmask = classprobsum.gt(0.75);

///////////////////////////////////
//                               //
//        BUILDING IMAGES        //
//                               //
///////////////////////////////////

// Define the year range
var startYear = ee.Number(site.first().get('start_year'));
var endYear = 2024;
var years = ee.List.sequence(startYear, endYear);

var yearList = years.getInfo().filter(function(y) {
  return y !== 2003;
});

// Build image list
var images = yearList.map(function(y) {
  var path = 'users/AidanHoutenUCL/ndvi_rasters/' + siteId + '/' + y;
  var image = ee.Image(path).select('NDVI')
    .set('year', y)
    .set('system:time_start', ee.Date.fromYMD(y, 6, 1).millis());
  return image;
});

// Convert to ee.ImageCollection
var coll = ee.ImageCollection(images);

var cleanedNDVI = coll.map(function(image) {
  return image.updateMask(classmask.not());
});

////////////////////////////////
//                            //
//        MANN-KENDALL        //
//                            //
////////////////////////////////

// MK analysis is used to detect monotonic trends i.e. a consistent increase/decrease over time

// Define temporal filter to pass all the chronologically later images
var afterFilter = ee.Filter.lessThan({
  leftField: 'year',
  rightField: 'year'
});

// Join the collection to itself based on year
// each image will store all the images that come after it in an 'after' property
var joined = ee.ImageCollection(ee.Join.saveAll('after').apply({
  primary: cleanedNDVI,
  secondary: cleanedNDVI,
  condition: afterFilter
}));

// Function to calculate sign between two images
// If NDVI at time j is more than NDVI at time i, the sign is 1
    // if opposite is true, sign is -1
    // if equal, sign is 0
var sign = function(i, j) {
  var diff = ee.Image(j).subtract(i);
  // Applying DynamicWorld masking to starting image
  var zero = ee.Image(0).updateMask(classmask.not());
  return zero
    .where(diff.gt(0), 1)
    .where(diff.lt(0), -1)
    .int();
};

// Calculate Mann-Kendall S Statistic
var kendall = ee.ImageCollection(joined.map(function(current) {
  var afterCollection = ee.ImageCollection.fromImages(current.get('after'));
  return afterCollection.map(function(image) {
    return ee.Image(sign(current, image));
  });
}).flatten()).reduce('sum', 2);

var kendall=kendall.clip(geometry)

// Visualize Mann-Kendall S Statistic
Map.centerObject(geometry);
Map.addLayer(kendall, {min: -100, max: 100, palette: ['red', 'white', 'green']}, 'Mann-Kendall S');

// Export result
Export.image.toDrive({
  image: kendall,
  description: 'MannKendall_' + siteId,
  folder: 'GEE_MK',
  fileNamePrefix: 'MannKendall_' + siteId,
  region: geometry,
  scale: 30,
  maxPixels: 1e13
});

////////////////////////////////
//                            //
//    VARIANCE CALCULATION    //
//                            //
////////////////////////////////

// Detects tied NDVI values
var groups = cleanedNDVI.map(function(i) {
  var matches = cleanedNDVI.map(function(j) {
    return i.eq(j);
  }).sum();
  return i.multiply(matches.gt(1));
});

// Compute tie group sizes in a sequence.  The first group is discarded.
var group = function(array) {
  var length = array.arrayLength(0);
  // Array of indices.  These are 1-indexed.
  var indices = ee.Image([1])
      .arrayRepeat(0, length)
      .arrayAccum(0, ee.Reducer.sum())
      .toArray(1);
  var sorted = array.arraySort();
  var left = sorted.arraySlice(0, 1);
  var right = sorted.arraySlice(0, 0, -1);
  // Indices of the end of runs.
  var mask = left.neq(right)
  // Always keep the last index, the end of the sequence.
      .arrayCat(ee.Image(ee.Array([[1]])), 0);
  var runIndices = indices.arrayMask(mask);
  // Subtract the indices to get run lengths.
  var groupSizes = runIndices.arraySlice(0, 1)
      .subtract(runIndices.arraySlice(0, 0, -1));
  return groupSizes;
};

// See equation 2.6 in Sen (1968) - implements a correction factor for ties in the data
// The sum of the below over all tied groups then subtracted from the general variance formula
var factors = function(image) {
  return image.expression('b() * (b() - 1) * (b() * 2 + 5)');
};

// Identifies runs of tied values (e.g. NDVI = 0.4 for 3 years in a row)
// Computes the length of each run
var groupSizes = group(groups.toArray());
var groupFactors = factors(groupSizes);
var groupFactorSum = groupFactors.arrayReduce('sum', [0])
      .arrayGet([0, 0]);

// Per-pixel Kendall's variance
var count = joined.count();
var kendallVariance = factors(count)
    .subtract(groupFactorSum)
    .divide(18)
    .float();

// Map.addLayer(kendallVariance, {min: 0, max: 10000}, 'kendallVariance');

////////////////////////////////////
//                                //
//    SIGNIFICANCE CALCULATION    //
//                                //
////////////////////////////////////

// Compute Z-statistics per-pixel based on the sign of the trend and its variance
var zero = kendall.multiply(kendall.eq(0));
var pos = kendall.multiply(kendall.gt(0)).subtract(1);
var neg = kendall.multiply(kendall.lt(0)).add(1);
var z = zero
    .add(pos.divide(kendallVariance.sqrt()))
    .add(neg.divide(kendallVariance.sqrt()));

// Map.addLayer(z, {min: -2, max: 2}, 'z');

// Function to convert Z to p-value using a cumulative distribution function
function eeCdf(z) {
  return ee.Image(0.5)
      .multiply(ee.Image(1).add(ee.Image(z).divide(ee.Image(2).sqrt()).erf()));
}

// Inverse cumulative distribution function
// Calculates the Z-score corresponding to a given p-value
function invCdf(p) {
  return ee.Image(2).sqrt()
      .multiply(ee.Image(p).multiply(2).subtract(1).erfInv());
}

// Compute P-values
var p = ee.Image(1).subtract(eeCdf(z.abs()));
Map.addLayer(p, {min: 0, max: 1}, 'p');

// Pixels that can have the null hypothesis (there is no trend) rejected
Map.addLayer(p.lte(0.025), {min: 0, max: 1}, 'significant trends');

// Export
Export.image.toDrive({
  image: p,
  description: 'PValues_' + siteId,
  folder: 'GEE_P',
  fileNamePrefix: 'PValues_' + siteId,
  region: geometry,
  scale: 30,
  maxPixels: 1e13
});

///////////////////////////////
//                           //
//        SEN'S SLOPE        //
//                           //
///////////////////////////////

// Define slope function between two images (NDVI diff / time diff in years)
var slope = function(i, j) {
  return ee.Image(j).subtract(i)
      .divide(ee.Image(j).date().difference(ee.Image(i).date(), 'year'))  // Use years
      .rename('slope')
      .float();
};

// Apply slope function to all image pairs
var slopeImages = ee.ImageCollection(joined.map(function(current) {
  var afterCollection = ee.ImageCollection.fromImages(current.get('after'));
  return afterCollection.map(function(image) {
      return ee.Image(slope(current, image));
  });
}).flatten());

// Reduce to median slope (Sen's slope)
var sensSlope = slopeImages.reduce(ee.Reducer.median()).rename('Sen_Slope');
var sensSlopeClipped = sensSlope.clip(geometry);

// Add to map
Map.addLayer(sensSlopeClipped, {min: -0.01, max: 0.01, palette: ['red', 'white', 'green']}, 'Sen\'s Slope');

// Export
Export.image.toDrive({
  image: sensSlopeClipped,
  description: 'SenSlope_' + siteId,
  folder: 'GEE_SS',
  fileNamePrefix: 'SenSlope_' + siteId,
  region: geometry,
  scale: 30,
  maxPixels: 1e13
});
New contributor
Aidan is a new contributor to this site. Take care in asking for clarification, commenting, and answering. Check out our Code of Conduct.

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.