class S2BiophysicalCalculator(ABC): def __init__(self): """Calculates BioPhysical property values from Sentinel-2 imagery as per the S2 toolbox products """ self._initialise_normalisation() self._initialise_network() def _initialise_normalisation(self): """Initialise the Normalisers to preprocess inputs according to the expected/validated minimums & maximums""" self.norm_b3 = Normaliser(x_min=0., x_max=0.253061520471542) self.norm_b4 = Normaliser(x_min=0., x_max=0.290393577911328) self.norm_b5 = Normaliser(x_min=0., x_max=0.305398915248555) self.norm_b6 = Normaliser(x_min=0.006637972542253, x_max=0.608900395797889) self.norm_b7 = Normaliser(x_min=0.013972727018939, x_max=0.753827384322927) self.norm_b8a = Normaliser(x_min=0.026690138082061, x_max=0.782011770669178) self.norm_b11 = Normaliser(x_min=0.016388074192258, x_max=0.493761397883092) self.norm_b12 = Normaliser(x_min=0., x_max=0.493025984460231) self.norm_cos_view_zenith = Normaliser(x_min=0.918595400582046, x_max=0.99999999999139) self.norm_cos_sun_zenith = Normaliser(x_min=0.342022871159208, x_max=0.936206429175402) self.norm_cos_rel_azimuth = Normaliser(x_min=-0.999999982118044, x_max=0.999999998910077) self._initialise_output_normalisation() @abstractmethod def _initialise_output_normalisation(self): """Define the normalisation parameters on the output to return outputs into the natural range of values MUST be overridden in concrete child classes. """ self.norm_output = None @abstractmethod def _initialise_network(self): """Define the network structure & parameterisation MUST be overridden in concrete child classes """ self.neuron_1 = None self.neuron_2 = None self.neuron_3 = None self.neuron_4 = None self.neuron_5 = None self.neuron_6 = None self.network = None def run(self, input_arr: np.ndarray, band_sequence: List[str] = DEFAULT_BAND_SEQUENCE, validate: bool = True) -> \ Union[float, np.float]: """Run the calculator on an input array By default, the calculator expects only the following bands to be passed in the sequence below: - B03 - B04 - B05 - B06 - B07 - B8a - B11 - B12 - COS_VIEW_ZENITH - COS_SUN_ZENITH - COS_REL_AZIMUTH If band values are to be passed in a different set or sequence, the band_sequence parameter must be passed with band names (matching those above) for each element in the input array. eg. ["extra_band_1", "COS_SUN_ZENITH", "B03", "B04", ..., "COS_REL_AZIMUTH", "extra_band_2"] :param input_arr: Input values for the calculator to use :param band_sequence: Names of bands included in the input array (names must match those used above for the required bands) :param validate: Flag for whether or not to apply validation ranges to the inputs :return: Scalar estimate of the biophysical property """ if not band_sequence == DEFAULT_BAND_SEQUENCE: band_idxs = [ band_sequence.index(elem) for elem in DEFAULT_BAND_SEQUENCE ] ordered_arr = np.array([input_arr[idx] for idx in band_idxs]) else: ordered_arr = input_arr if validate: ordered_arr = self._validate(ordered_arr) normalised_arr = self._normalise(ordered_arr) y_norm = self._compute(normalised_arr) y = self.norm_output.denormalise(y_norm) return y def _validate(self, input_arr: np.ndarray) -> np.ndarray: """Validate input band values against the 'definition domain for inputs' NB. We validate against the min & max input values, but we do NOT check that inputs lie in valid cells of the convex hull :param input_arr: Band values to be validated :return: Band values after passing through validation. ValueError is raised if any values fail. """ validation_ranges = self.VALIDATION_RANGES for band_index, band_name in enumerate(validation_ranges): band_value = input_arr[band_index] if validation_ranges.get(band_name).get( "min") <= band_value <= validation_ranges.get( band_name).get("max"): continue else: raise ValueError( f"Band {band_name} failed validation because it is expected to fall in the range [{validation_ranges.get(band_name).get('min')}, {validation_ranges.get(band_name).get('max')}]" ) return input_arr def _normalise(self, band_values: np.ndarray) -> np.ndarray: """Normalise input band values to predefined ranges before passing to the neural network for calculation. :param band_values: Band values to be normalised :return: Normalised band values """ return np.array([ self.norm_b3.normalise(band_values[0]), self.norm_b4.normalise(band_values[1]), self.norm_b5.normalise(band_values[2]), self.norm_b6.normalise(band_values[3]), self.norm_b7.normalise(band_values[4]), self.norm_b8a.normalise(band_values[5]), self.norm_b11.normalise(band_values[6]), self.norm_b12.normalise(band_values[7]), self.norm_cos_view_zenith.normalise(band_values[8]), self.norm_cos_sun_zenith.normalise(band_values[9]), self.norm_cos_rel_azimuth.normalise(band_values[10]), ]) def _compute(self, normalised_arr: np.ndarray) -> np.float: """Calculates normalised FAPAR from normalised input band values :param normalised_arr: Normalised band values :return: Normalised FAPAR estimate """ return self.network.forward(normalised_arr)
def test_normaliser_out_of_range(): """Confirm that input above x_max is normalised to >1""" normaliser = Normaliser(x_min=10, x_max=20) normalised_input = normaliser.normalise(25) expected_output = 2. assert normalised_input == expected_output
def test_normaliser_minimum(): """Confirm that input equal to x_min is normalised to -1""" normaliser = Normaliser(x_min=10, x_max=20) normalised_input = normaliser.normalise(10) expected_output = -1. assert normalised_input == expected_output
def test_normaliser_middle(): """Confirm that input equal to midway between x_min & x_max is normalised to 0.""" normaliser = Normaliser(x_min=10, x_max=20) normalised_input = normaliser.normalise(15) expected_output = 0. assert normalised_input == expected_output