diff --git a/manifests/examples/builtins/time-converter/success.yaml b/manifests/examples/builtins/time-converter/success.yaml new file mode 100644 index 000000000..30c5d987e --- /dev/null +++ b/manifests/examples/builtins/time-converter/success.yaml @@ -0,0 +1,24 @@ +name: time-converter demo +description: successful path +tags: +initialize: + plugins: + time-converter: + method: TimeConverter + path: builtin + global-config: + input-parameter: "energy-per-year" + original-time-unit: "year" + new-time-unit: "duration" + output-parameter: "energy-per-duration" +tree: + children: + child: + pipeline: + - time-converter + config: + defaults: + energy-per-year: 10000 + inputs: + - timestamp: 2023-08-06T00:00 + duration: 3600 diff --git a/src/__tests__/if-run/builtins/time-converter.test.ts b/src/__tests__/if-run/builtins/time-converter.test.ts new file mode 100644 index 000000000..ae55daf5d --- /dev/null +++ b/src/__tests__/if-run/builtins/time-converter.test.ts @@ -0,0 +1,126 @@ +import {ERRORS} from '@grnsft/if-core/utils'; + +import {TimeConverter} from '../../../if-run/builtins/time-converter'; + +import {STRINGS} from '../../../if-run/config'; + +const {GlobalConfigError, InputValidationError} = ERRORS; +const {MISSING_GLOBAL_CONFIG} = STRINGS; + +describe('builtins/time-converter: ', () => { + describe('TimeConverter: ', () => { + const globalConfig = { + 'input-parameter': 'energy-per-year', + 'original-time-unit': 'year', + 'new-time-unit': 'duration', + 'output-parameter': 'energy-per-duration', + }; + const parametersMetadata = { + inputs: {}, + outputs: {}, + }; + const timeConverter = TimeConverter(globalConfig, parametersMetadata); + + describe('init: ', () => { + it('successfully initalized.', () => { + expect(timeConverter).toHaveProperty('metadata'); + expect(timeConverter).toHaveProperty('execute'); + }); + }); + + describe('execute(): ', () => { + it('successfully applies TimeConverter strategy to given input.', () => { + expect.assertions(1); + + const expectedResult = [ + { + timestamp: '2021-01-01T00:00:00Z', + duration: 3600, + 'energy-per-year': 10000, + 'energy-per-duration': 1.140795, + }, + ]; + + const result = timeConverter.execute([ + { + timestamp: '2021-01-01T00:00:00Z', + duration: 3600, + 'energy-per-year': 10000, + }, + ]); + + expect(result).toStrictEqual(expectedResult); + }); + + it('throws an error when global config is not provided.', () => { + const config = undefined; + const timeConverter = TimeConverter(config!, parametersMetadata); + + expect.assertions(1); + + try { + timeConverter.execute([ + { + timestamp: '2021-01-01T00:00:00Z', + duration: 3600, + 'energy-per-year': 10000, + }, + ]); + } catch (error) { + expect(error).toStrictEqual( + new GlobalConfigError(MISSING_GLOBAL_CONFIG) + ); + } + }); + + it('throws an error on missing params in input.', () => { + expect.assertions(1); + + try { + timeConverter.execute([ + { + timestamp: '2021-01-01T00:00:00Z', + duration: 3600, + }, + ]); + } catch (error) { + expect(error).toStrictEqual( + new InputValidationError( + '"energy-per-year" parameter is required. Error code: invalid_type.' + ) + ); + } + }); + + it('returns a result when `new-time-unit` is a different time unit than `duration`.', () => { + expect.assertions(1); + const newConfig = { + 'input-parameter': 'energy-per-year', + 'original-time-unit': 'year', + 'new-time-unit': 'month', + 'output-parameter': 'energy-per-duration', + }; + const timeConverter = TimeConverter(newConfig, parametersMetadata); + + const data = [ + { + timestamp: '2021-01-01T00:00:00Z', + duration: 3600, + 'energy-per-year': 10000, + }, + ]; + const response = timeConverter.execute(data); + const expectedResult = [ + { + timestamp: '2021-01-01T00:00:00Z', + duration: 3600, + 'energy-per-year': 10000, + 'energy-per-duration': 832.886522, + }, + ]; + + expect(response).toEqual(expectedResult); + }); + }); + }); +}); diff --git a/src/if-run/builtins/index.ts b/src/if-run/builtins/index.ts index 36a414c95..774533baf 100644 --- a/src/if-run/builtins/index.ts +++ b/src/if-run/builtins/index.ts @@ -12,4 +12,5 @@ export {CSVLookup} from './csv-lookup'; export {Shell} from './shell'; export {Regex} from './regex'; export {Copy} from './copy-param'; +export {TimeConverter} from './time-converter'; export {TimeSync} from './time-sync'; diff --git a/src/if-run/builtins/time-converter/README.md b/src/if-run/builtins/time-converter/README.md new file mode 100644 index 000000000..b6c945c20 --- /dev/null +++ b/src/if-run/builtins/time-converter/README.md @@ -0,0 +1,136 @@ +# Time Converter + +`time-conversion` is a generic plugin for converting time values from a specified time unit to a new given time unit. + +You provide the energy value, the time unit associated with this energy, and a new time unit to which you want to convert it. + +For example, you could add `energy-per-year`, the time unit `year`, and the new time unit `duration`. The `energy-per-duration` would then be added to every observation in your input array as the converted value of `energy-per-year`, `year` and `duration`. + +## Parameters + +### Plugin config + +These parameters are required in global config: + +- `input-parameter`: a string that should match an existing key in the `inputs` array +- `original-time-unit`: a string that defines the time unit of the `input-parameter`. The original time unit should be a valid unit, like `year`, `month`, `day`, `hour` and so on +- `new-time-unit`: a string that defines the new time unit that the `input-parameter` value should be converted to. The time unit can be `duration`(in which case it grabs the value from the `duration` in the input), or can be other time unit like `second`, `month`, `day`, `week` and so on +- `output-parameter`: a string defining the name to use to add the result of converting the input parameter to the output array + +### Plugin parameter metadata + +The `parameter-metadata` section contains information about `description` and `unit` of the parameters of the inputs and outputs + +- `inputs`: describe parameters of the `input-parameter` of the global config. Each parameter has: + + - `description`: description of the parameter + - `unit`: unit of the parameter + - `aggregation-method`: the aggregation method of the parameter (can be `sum`, `avg` or `none`) + +- `outputs`: describe the parameter of the `output-parameter` of the global config. The parameter has the following attributes: + - `description`: description of the parameter + - `unit`: unit of the parameter + - `aggregation-method`: the aggregation method of the parameter (can be `sum`, `avg` or `none`) + +### Inputs + +The `input-parameter` must be available in the input array. + +## Returns + +- `output-parameter`: the converted energy of the `input-parameter` with the parameter name defined by `output-parameter` in global config. + +## Calculation + +```pseudocode +output = input-parameter / original-time-unit * new-time-unit +``` + +## Implementation + +To run the plugin, you must first create an instance of `TimeConverter`. Then, you can call `execute()`. + +```typescript +const config = { + 'input-parameter': 'energy-per-year', + 'original-time-unit': 'year', + 'new-time-unit': 'duration', + 'output-parameter': 'energy-per-duration', +}; + +const timeConverter = TimeConverter(config, parametersMetadata); +const result = timeConverter.execute([ + { + timestamp: '2021-01-01T00:00:00Z', + duration: 3600, + 'energy-per-year': 10000, + }, +]); +``` + +## Example manifest + +IF users will typically call the plugin as part of a pipeline defined in a manifest file. In this case, instantiating the plugin is handled by and does not have to be done explicitly by the user. The following is an example manifest that calls `time-coverstion`: + +```yaml +name: time-coverstion demo +description: +tags: +initialize: + plugins: + time-converter: + method: TimeConverter + path: builtin + global-config: + input-parameter: 'energy-per-year' + original-time-unit: 'year' + new-time-unit: 'duration' + output-parameter: 'energy-per-duration' +tree: + children: + child: + pipeline: + - time-converter + config: + defaults: + energy-per-year: 10000 + inputs: + - timestamp: 2023-08-06T00:00 + duration: 3600 +``` + +You can run this example by saving it as `./examples/manifests/time-coverstion.yml` and executing the following command from the project root: + +```sh +if-run --manifest ./examples/manifests/time-coverstion.yml --output ./examples/outputs/time-coverstion.yml +``` + +The results will be saved to a new `yaml` file in `./examples/outputs`. + +## Errors + +`TimeConverter` exposes two of the IF error classes. + +### GlobalConfigError + +You will receive an error starting `GlobalConfigError: ` if you have not provided the expected configuration data in the plugin's `initialize` block. + +The required parameters are: + +- `input-parameter`: this must be a string, which is the name of parameter in the `inputs` array +- `original-time-unit`: this must be a string of time units (`minutes`, `seconds` and so on) +- `new-time-unit`: this must be a string of time units (e.g. `duration`, `minutes`, `seconds` and so on) +- `output-parameter`: this must be a string + +You can fix this error by checking you are providing valid values for each parameter in the config. + +### `MissingInputDataError` + +This error arises when a necessary piece of input data is missing from the `inputs` array. +Every element in the `inputs` array must contain: + +- `timestamp` +- `duration` +- whatever values you passed to `input-parameter` + +For more information on our error classes, please visit [our docs](https://if.greensoftware.foundation/reference/errors). diff --git a/src/if-run/builtins/sci/config.ts b/src/if-run/builtins/time-converter/config.ts similarity index 64% rename from src/if-run/builtins/sci/config.ts rename to src/if-run/builtins/time-converter/config.ts index edbf8881e..e6cdec1bf 100644 --- a/src/if-run/builtins/sci/config.ts +++ b/src/if-run/builtins/time-converter/config.ts @@ -24,17 +24,17 @@ export const TIME_UNITS_IN_SECONDS: Record = { wks: 604800, week: 604800, weeks: 604800, - m: 2419200, - mnth: 2419200, - mth: 2419200, - mnths: 2419200, - mths: 2419200, - month: 2419200, - months: 2419200, - y: 31536000, - ys: 31536000, - yr: 31536000, - yrs: 31536000, - year: 31536000, - years: 31536000, + m: 2628336, + mnth: 2628336, + mth: 2628336, + mnths: 2628336, + mths: 2628336, + month: 2628336, + months: 2628336, + y: 31556952, + ys: 31556952, + yr: 31556952, + yrs: 31556952, + year: 31556952, + years: 31556952, }; diff --git a/src/if-run/builtins/time-converter/index.ts b/src/if-run/builtins/time-converter/index.ts new file mode 100644 index 000000000..cb25951a9 --- /dev/null +++ b/src/if-run/builtins/time-converter/index.ts @@ -0,0 +1,103 @@ +import {z} from 'zod'; +import {ERRORS} from '@grnsft/if-core/utils'; +import { + ExecutePlugin, + PluginParams, + PluginParametersMetadata, + ConfigParams, +} from '@grnsft/if-core/types'; + +import {validate} from '../../../common/util/validations'; + +import {STRINGS} from '../../config'; + +import {TIME_UNITS_IN_SECONDS} from './config'; + +const {GlobalConfigError} = ERRORS; +const {MISSING_GLOBAL_CONFIG} = STRINGS; + +export const TimeConverter = ( + globalConfig: ConfigParams, + parametersMetadata: PluginParametersMetadata +): ExecutePlugin => { + const metadata = { + kind: 'execute', + inputs: parametersMetadata?.inputs, + outputs: parametersMetadata?.outputs, + }; + + const execute = (inputs: PluginParams[]) => { + const safeGlobalConfig = validateGlobalConfig(); + const inputParameter = safeGlobalConfig['input-parameter']; + const outputParameter = safeGlobalConfig['output-parameter']; + + return inputs.map(input => { + validateInput(input, inputParameter); + + return { + ...input, + [outputParameter]: calculateEnergy(input), + }; + }); + }; + + /** + * Calculates the energy for given period. + */ + const calculateEnergy = (input: PluginParams) => { + const originalTimeUnit = globalConfig['original-time-unit']; + const originalTimeUnitInSeoncds = TIME_UNITS_IN_SECONDS[originalTimeUnit]; + const energyPerPeriod = input[globalConfig['input-parameter']]; + const newTimeUnit = + globalConfig['new-time-unit'] === 'duration' + ? input.duration + : TIME_UNITS_IN_SECONDS[globalConfig['new-time-unit']]; + const result = (energyPerPeriod / originalTimeUnitInSeoncds) * newTimeUnit; + + return Number(result.toFixed(6)); + }; + + /** + * Checks for required fields in input. + */ + const validateInput = (input: PluginParams, inputParameter: string) => { + const schema = z.object({ + duration: z.number().gte(1), + [inputParameter]: z.number(), + }); + + return validate>(schema, input); + }; + + /** + * Checks global config value are valid. + */ + const validateGlobalConfig = () => { + if (!globalConfig) { + throw new GlobalConfigError(MISSING_GLOBAL_CONFIG); + } + + const timeUnitsValues = Object.keys(TIME_UNITS_IN_SECONDS); + const originalTimeUnitValuesWithDuration = [ + 'duration', + ...timeUnitsValues, + ] as const; + const originalTimeUnitValues = timeUnitsValues as [string, ...string[]]; + + const globalConfigSchema = z.object({ + 'input-parameter': z.string(), + 'original-time-unit': z.enum(originalTimeUnitValues), + 'new-time-unit': z.enum(originalTimeUnitValuesWithDuration), + 'output-parameter': z.string().min(1), + }); + + return validate>( + globalConfigSchema, + globalConfig + ); + }; + return { + metadata, + execute, + }; +};