diff --git a/docs/implementations/watt-time.md b/docs/implementations/watt-time.md index 9ca0fbd53..6b761b9b3 100644 --- a/docs/implementations/watt-time.md +++ b/docs/implementations/watt-time.md @@ -10,7 +10,8 @@ WattTime technology—based on real-time grid data, cutting-edge algorithms, and WattTime Model provides a way to calculate emissions for a given time in a specific location. The model is based on the WattTime API. The model uses the following inputs: -* location: Location of the software system ({latitude:0.0, longitude:0.0}) +* latitude: Location of the software system (latitude in decimal degrees). +* longitude: Location of the software system (longitude in decimal degrees). * timestamp: Timestamp of the recorded event (2021-01-01T00:00:00Z) RFC3339 * duration: Duration of the recorded event in seconds (3600) @@ -22,13 +23,17 @@ Limitations: * Emissions are aggregated for every 5 minutes regardless of the granularity of the observations. ### Authentication + + +WattTime API requires activation of subscription before usage. Please refer to WattTime website for more information. + **Required Parameters:** * username: Username for the WattTime API * password: Password for the WattTime API - +### Typescript Usage ```typescript // environment variable configuration // export WATT_TIME_USERNAME=test1 @@ -38,8 +43,18 @@ const env_model = await new WattTimeGridEmissions().configure('watt-time', { username: process.env.WATT_TIME_USERNAME, password: process.env.WATT_TIME_PASSWORD, }); +const observations = [ + { + timestamp: '2021-01-01T00:00:00Z', + latitude: 43.22, + longitude: -80.22, + duration: 3600, + }, +]; +const results = env_model.calculateEmissions(observations); ``` +### IMPL Usage #### Environment Variable based configuration for IMPL ```yaml # environment variable config , prefix the environment variables with "ENV" to load them inside the model. @@ -50,9 +65,8 @@ config: password: ENV_WATT_TIME_PASSWORD observations: - timestamp: 2021-01-01T00:00:00Z - location: - latitude: 43.22 - longitude: -80.22 + latitude: 43.22 + longitude: -80.22 duration: 3600 ``` #### Static configuration for IMPL @@ -62,13 +76,7 @@ config: password: password observations: - timestamp: 2021-01-01T00:00:00Z - location: - latitude: 43.22 - longitude: -80.22 + latitude: 43.22 + longitude: -80.22 duration: 3600 ``` - - -### Calculations - - diff --git a/src/lib/watt-time/index.test.ts b/src/lib/watt-time/index.test.ts index 9cec8adeb..3a1b384de 100644 --- a/src/lib/watt-time/index.test.ts +++ b/src/lib/watt-time/index.test.ts @@ -14,12 +14,16 @@ mockAxios.get.mockImplementation(url => { switch (url) { case 'https://api2.watttime.org/v2/login': return Promise.resolve({ + status: 200, data: { token: 'test_token', }, }); case 'https://api2.watttime.org/v2/data': - return Promise.resolve({data: DATA}); + return Promise.resolve({ + data: DATA, + status: 200, + }); } }); describe('watt-time:configure test', () => { @@ -32,41 +36,87 @@ describe('watt-time:configure test', () => { await expect( model.calculate([ { - location: { - latitude: 37.7749, - longitude: -122.4194, - }, + latitude: 37.7749, + longitude: -122.4194, timestamp: '2021-01-01T00:00:00Z', duration: 1200, }, ]) ).resolves.toStrictEqual([ { - location: { + latitude: 37.7749, + longitude: -122.4194, + timestamp: '2021-01-01T00:00:00Z', + duration: 1200, + 'grid-ci': 2185.332173907599, + }, + ]); + await expect( + model.calculate([ + { latitude: 37.7749, longitude: -122.4194, + timestamp: '2021-01-01T00:00:00Z', + duration: 120, }, + ]) + ).resolves.toStrictEqual([ + { + latitude: 37.7749, + longitude: -122.4194, timestamp: '2021-01-01T00:00:00Z', - duration: 1200, - 'grid-ci': 2185.332173907599, + duration: 120, + 'grid-ci': 2198.0087539832293, + }, + ]); + await expect( + model.calculate([ + { + latitude: 37.7749, + longitude: -122.4194, + timestamp: '2021-01-01T00:00:00Z', + duration: 300, + }, + ]) + ).resolves.toStrictEqual([ + { + latitude: 37.7749, + longitude: -122.4194, + timestamp: '2021-01-01T00:00:00Z', + duration: 300, + 'grid-ci': 2198.0087539832293, + }, + ]); + await expect( + model.calculate([ + { + latitude: 37.7749, + longitude: -122.4194, + timestamp: '2021-01-01T00:00:00Z', + duration: 360, + }, + ]) + ).resolves.toStrictEqual([ + { + latitude: 37.7749, + longitude: -122.4194, + timestamp: '2021-01-01T00:00:00Z', + duration: 360, + 'grid-ci': 2193.5995087395318, }, ]); await expect( model.calculate([ { - location: { - latitude: 37.7749, - longitude: -122.4194, - }, + latitude: 37.7749, + longitude: -122.4194, timestamp: '2021-01-01T00:00:00Z', duration: 3600, }, { - location: { - latitude: 37.7749, - longitude: -122.4194, - }, + latitude: 37.7749, + longitude: -122.4194, timestamp: '2021-01-02T01:00:00Z', duration: 3600, }, diff --git a/src/lib/watt-time/index.ts b/src/lib/watt-time/index.ts index fa55eb32b..8759853ae 100644 --- a/src/lib/watt-time/index.ts +++ b/src/lib/watt-time/index.ts @@ -23,11 +23,13 @@ export class WattTimeGridEmissions implements IImpactModelInterface { this.token = process.env[this.token.slice(4)] ?? ''; } if (this.token === '') { + // Extracting username and password from authParams let username = 'username' in authParams ? (authParams['username'] as string) : ''; let password = 'password' in authParams ? (authParams['password'] as string) : ''; + // if username or password is ENV_, then extract the value from the environment variable if (username.startsWith('ENV_')) { username = process.env[username.slice(4)] ?? ''; } @@ -36,17 +38,18 @@ export class WattTimeGridEmissions implements IImpactModelInterface { password = process.env[password.slice(4)] ?? ''; } + // WattTime API requires username and password / token if (username === '' || password === '') { throw new Error('Missing username or password & token'); } + // Login to WattTime API to get a token const tokenResponse = await axios.get(`${this.baseUrl}/login`, { auth: { username, password, }, }); - console.log('TOKEN RESP', tokenResponse); if ( tokenResponse === undefined || tokenResponse.data === undefined || @@ -64,18 +67,76 @@ export class WattTimeGridEmissions implements IImpactModelInterface { if (!Array.isArray(observations)) { throw new Error('observations should be an array'); } - let starttime = dayjs('9999-12-31'); - let endtime = dayjs('1970-01-01'); - const blockpairs = []; - const times = []; + // validate observations for location data + timestamp + duration + this.validateObservations(observations); + // determine the earliest start and total duration of all observation blocks + const {startTime, fetchDuration} = + this.determineObservationStartEnd(observations); + // fetch data from WattTime API for the entire duration + const wattimedata = await this.fetchData({ + timestamp: startTime.format(), + duration: fetchDuration, + ...observations[0], + }); + // for each observation block, calculate the average emission observations.map((observation: KeyValuePair) => { - if (!('location' in observation)) { - throw new Error('location is missing'); + const observationStart = dayjs(observation.timestamp); + const observationEnd = observationStart.add( + observation.duration, + 'seconds' + ); + const {datapoints, data} = this.getWattTimeDataForDuration( + wattimedata, + observationStart, + observationEnd + ); + const emissionSum = data.reduce((a: number, b: number) => a + b, 0); + if (datapoints === 0) { + throw new Error( + 'Did not receive data from WattTime API for the observation block.' + ); } + observation['grid-ci'] = emissionSum / datapoints; + }); + + return observations; + } + + private getWattTimeDataForDuration( + wattimedata: KeyValuePair[], + observationStart: dayjs.Dayjs, + observationEnd: dayjs.Dayjs + ): {datapoints: number; data: number[]} { + let datapoints = 0; + const data = wattimedata.map((data: KeyValuePair) => { + // WattTime API returns full data for the entire duration. + // if the data point is before the observation start, ignore it + if (dayjs(data.point_time).isBefore(observationStart)) { + return 0; + } + // if the data point is after the observation end, ignore it. + // if the data point is exactly the same as the observation end, ignore it if ( - !('latitude' in observation.location) || - !('longitude' in observation.location) + dayjs(data.point_time).isAfter(observationEnd) || + dayjs(data.point_time).format() === dayjs(observationEnd).format() ) { + return 0; + } + // lbs/MWh to Kg/MWh by dividing by 0.453592 (0.453592 Kg/lbs) + // (Kg/MWh == g/kWh) + // convert to kg/KWh by dividing by 1000. (1MWh = 1000KWh) + // convert to g/KWh by multiplying by 1000. (1Kg = 1000g) + // hence each other cancel out and g/KWh is the same as kg/MWh + const grid_emission = data.value / 0.45359237; + datapoints += 1; + return grid_emission; + }); + return {datapoints, data}; + } + + private validateObservations(observations: object[]) { + observations.forEach((observation: KeyValuePair) => { + if (!('latitude' in observation) || !('longitude' in observation)) { throw new Error('latitude or longitude is missing'); } if (!('timestamp' in observation)) { @@ -84,26 +145,30 @@ export class WattTimeGridEmissions implements IImpactModelInterface { if (!('duration' in observation)) { throw new Error('duration is missing'); } + }); + } + + private determineObservationStartEnd(observations: object[]) { + // largest possible start time + let starttime = dayjs('9999-12-31'); + // smallest possible end time + let endtime = dayjs('1970-01-01'); + observations.forEach((observation: KeyValuePair) => { const duration = observation.duration; - times.push(dayjs(observation.timestamp)); + // if the observation timestamp is before the current starttime, set it as the new starttime starttime = dayjs(observation.timestamp).isBefore(starttime) ? dayjs(observation.timestamp) : starttime; + + // if the observation timestamp + duration is after the current endtime, set it as the new endtime endtime = dayjs(observation.timestamp) .add(duration, 'seconds') .isAfter(endtime) ? dayjs(observation.timestamp).add(duration, 'seconds') : endtime; - blockpairs.push({ - start: dayjs(observation.timestamp), - end: dayjs(observation.timestamp).add(duration, 'seconds'), - }); - console.log('starttime', starttime.format()); - console.log('endtime', endtime.format()); }); const fetchDuration = endtime.diff(starttime, 'seconds'); - console.log('fetchDuration', fetchDuration); if (fetchDuration > 32 * 24 * 60 * 60) { throw new Error( 'duration is too long.WattTime API only supports up to 32 days. All observations must be within 32 days of each other. Duration of ' + @@ -111,54 +176,7 @@ export class WattTimeGridEmissions implements IImpactModelInterface { ' seconds is too long.' ); } - const wattimedata = await this.fetchData({ - location: observations[0].location, - timestamp: starttime.format(), - duration: fetchDuration, - }); - console.log('wattime data:', wattimedata); - observations.map((observation: KeyValuePair) => { - const observationStart = dayjs(observation.timestamp); - const observationDuration = observation.duration; - const observationEnd = observationStart.add( - observationDuration, - 'seconds' - ); - console.log(observationStart.format()); - console.log(observationEnd.format()); - let datapoints = 0; - const data = wattimedata.map((data: KeyValuePair) => { - if (dayjs(data.point_time).isBefore(observationStart)) { - return 0; - } - if ( - dayjs(data.point_time).isAfter(observationEnd) || - dayjs(data.point_time).format() === dayjs(observationEnd).format() - ) { - return 0; - } - console.log('measuring for', observation.timestamp); - console.log('data', data); - // lbs/MWh to kg/MWh to g/kWh (kg/MWh == g/kWh as a ratio) - const grid_emission = data.value / 0.45359237; - console.log('emissions raw:', data.value); - // convert to kg/kWh by dividing by 1000. (1MWh = 1000kWh) - // convert to g/kWh by multiplying by 1000. (1kg = 1000g) - // hence each other cancel out and g/kWh is the same as kg/MWh - datapoints += 1; - return grid_emission; - }); - const emissionSum = data.reduce((a: number, b: number) => a + b, 0); - console.log('data', data); - if (datapoints === 0) { - throw new Error('Did not receive data from WattTime API'); - } - console.log('datapoints', datapoints, data.length); - console.log('emissionAvg', emissionSum / datapoints); - observation['grid-ci'] = emissionSum / datapoints; - }); - - return observations; + return {startTime: starttime, fetchDuration}; } async fetchData(observation: KeyValuePair): Promise { @@ -169,17 +187,27 @@ export class WattTimeGridEmissions implements IImpactModelInterface { } const params = { - latitude: observation.location.latitude, - longitude: observation.location.longitude, + latitude: observation.latitude, + longitude: observation.longitude, starttime: dayjs(observation.timestamp).format('YYYY-MM-DDTHH:mm:ssZ'), endtime: dayjs(observation.timestamp).add(duration, 'seconds'), }; - const result = await axios.get(`${this.baseUrl}/data`, { - params, - headers: { - Authorization: `Bearer ${this.token}`, - }, - }); + const result = await axios + .get(`${this.baseUrl}/data`, { + params, + headers: { + Authorization: `Bearer ${this.token}`, + }, + }) + .catch(e => { + throw new Error('Error fetching data from WattTime API.' + e); + }); + if (result.status !== 200) { + throw new Error('Error fetching data from WattTime API.' + result.status); + } + if (!('data' in result) || !Array.isArray(result.data)) { + throw new Error('Invalid response from WattTime API.'); + } return result.data.sort((a: any, b: any) => { return dayjs(a.point_time).unix() > dayjs(b.point_time).unix() ? 1 : -1; });