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

Electricity Maps Support #9

Merged
merged 3 commits into from
Feb 9, 2024
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ Currently the extension supports different measurement scopes:

By default `process` scope is used. The user can change it by CLI flag `--PowerUsageDisplay.measurement_scope` to `jupyter lab` command. Alternatively, it can be configured in `jupyter_server_config.json` in [Jupyter config directory](https://docs.jupyter.org/en/latest/use/jupyter-directories.html#configuration-files).

#### Electricity Maps API token

An API token for electricity maps emission factor. By default API requests are made from jupyter server as they involve including authentication token. These are called proxied requests. If they fail, API requests directly from the browser will be made using the API token configured in the frontend extension. Users should configure the token on the server config as exposing API token in browsers can pose security issues. It can be set on CLI using `--PowerUsageDisplay.emaps_access_token=<token>`.

### Frontend extension config

![Frontend extension settings](https://raw.githubusercontent.com/mahendrapaipuri/jupyter-power-usage/main/doc/frontend-settings.png)
Expand All @@ -76,6 +80,10 @@ The frontend extension settings can be accessed by `Settings -> Advanced Setting

**Emissions Estimation Settings**

- `Source of emission factor`: Currently [Electricity Maps](https://www.electricitymaps.com/) and [RTE eCO<sub>2</sub> mix](https://www.rte-france.com/en/eco2mix/co2-emissions) are supported. Note that RTE eCO<sub>2</sub> mix data is only available for France.

- `Electricity Maps Access token`: An API access token for Electricity Maps (See [Server Config](#electricity-maps-api-token)).

- `Country code`: Currently only data for France is supported. The realtime emission factor from [RTE eCO<sub>2</sub> mix](https://www.rte-france.com/en/eco2mix/co2-emissions). We encourage users to add support for other countries. Please check [`CONTRIBUTING.md`](CONTRIBUTING.md) on how to do it. If your country is not available in the list, leave it blank.

- `Refresh rate`: This defines how often the emission factor is updated in ms. For [RTE eCO<sub>2</sub> mix](https://www.rte-france.com/en/eco2mix/co2-emissions) data, it is updated every 30 min and has a rate limit of 50000 API requests per month.
Expand Down
Binary file modified doc/frontend-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions jupyter_power_usage/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from jupyter_server.utils import url_path_join as ujoin

from ._version import __version__
from .api import ElectrictyMapsHandler
from .api import PowerMetricHandler
from .config import PowerUsageDisplay
from .metrics import CpuPowerUsage
Expand Down Expand Up @@ -37,5 +38,9 @@ def load_jupyter_server_extension(server_app):
'gpu_power_usage': gpu_power_usage,
},
),
(
ujoin(base_url, 'api/metrics/v1/emission_factor/emaps') + '(.*)',
ElectrictyMapsHandler,
),
],
)
58 changes: 58 additions & 0 deletions jupyter_power_usage/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@

import psutil
from jupyter_server.base.handlers import JupyterHandler
from jupyter_server.utils import url_escape
from jupyter_server.utils import url_path_join
from tornado import web
from tornado.concurrent import run_on_executor
from tornado.httpclient import AsyncHTTPClient
from tornado.httpclient import HTTPError
from tornado.httpclient import HTTPRequest
from tornado.httputil import url_concat


class PowerMetricHandler(JupyterHandler):
Expand Down Expand Up @@ -93,3 +99,55 @@ async def get(self):
@run_on_executor
def _get_cpu_energy_usage(self, all_processes):
return self.cpu_power_usage.get_power_usage(all_processes)


class ElectrictyMapsHandler(JupyterHandler):
"""
A proxy for the Electricity Maps API v3.

The purpose of this proxy is to provide authentication to the API requests.
"""

client = AsyncHTTPClient()

def initialize(self):
# Get access token(s) from config
self.access_tokens = {}
self.access_tokens['emaps'] = self.settings[
'jupyter_power_usage_config'
].emaps_access_token

@web.authenticated
async def get(self, path):
"""Return emission factor data from electticity maps"""
try:
query = self.request.query_arguments
params = {key: query[key][0].decode() for key in query}
api_path = url_path_join('https://api.electricitymap.org', url_escape(path))

access_token = params.pop('access_token', None)
if self.access_tokens['emaps']:
# Preferentially use the config access_token if set
token = self.access_tokens['emaps']
elif access_token:
token = access_token
else:
token = ''

api_path = url_concat(api_path, params)

request = HTTPRequest(
api_path,
user_agent='JupyterLab Power Usage',
headers={'auth-token': f'{token}'},
)
response = await self.client.fetch(request)
data = json.loads(response.body.decode('utf-8'))

# Send the results back.
self.finish(json.dumps(data))

except HTTPError as err:
self.set_status(err.code)
message = err.response.body if err.response else str(err.code)
self.finish(message)
5 changes: 5 additions & 0 deletions jupyter_power_usage/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from traitlets import Enum
from traitlets import Unicode
from traitlets.config import Configurable

# Minimum measurement period in millisec.
Expand All @@ -25,3 +26,7 @@ class PowerUsageDisplay(Configurable):
GPU level power usage is reported always.
""",
).tag(config=True)

emaps_access_token = Unicode(
'', help="An API access token for Electricty Maps."
).tag(config=True)
32 changes: 26 additions & 6 deletions schema/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@
"title": "Emissions Estimation Settings",
"description": "Settings for the estimating eCO2 emissions",
"default": {
"countryCode": "",
"countryCode": "FR",
"refreshRate": 1800000,
"factor": 475
"factor": 475,
"emapsAccessToken": ""
},
"$ref": "#/definitions/emissions"
}
Expand Down Expand Up @@ -76,16 +77,35 @@
"emissions": {
"type": "object",
"properties": {
"source": {
"title": "Source of emission factor",
"description": "Emission factor fetched from this source will be used. RTE eCO2 mix source is only available for France.",
"oneOf": [
{
"const": "emaps",
"title": "Electricity Maps"
},
{
"const": "rte",
"title": "RTE eCO2 mix"
}
],
"default": "rte",
"type": "string"
},
"emapsAccessToken": {
"title": "Electricity Maps Access token",
"description": "API Access token for Electricity Maps.",
"type": "string"
},
"countryCode": {
"title": "Country code",
"description": "ISO code of the country of which emission factor will be used",
"enum": ["", "fr"],
"default": "",
"description": "ISO code of the country of which emission factor will be used.",
"type": "string"
},
"refreshRate": {
"title": "Refresh rate (ms)",
"description": "eCO2 emission factor will be updated at this interval. Do not use too small intervals as these sort of APIs are rate limited.",
"description": "eCO2 emission factor will be updated at this interval. Do not use too small intervals as these APIs are rate limited.",
"type": "number"
},
"factor": {
Expand Down
95 changes: 89 additions & 6 deletions src/emissionsHandler.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { URLExt } from '@jupyterlab/coreutils';

namespace France {
import { ServerConnection } from '@jupyterlab/services';

import { EmissionFactor } from './model';

namespace RTE {
// Open Data Soft base and API URLs
const OPENDATASOFT_API_BASEURL = 'https://odre.opendatasoft.com';
const OPENDATASOFT_API_PATH = '/api/records/1.0/search/';

export const getOpenDataSoftEmissions = async (): Promise<number> => {
export const getOpenDataSoftEmissionFactor = async (): Promise<number> => {
// Get current date in yyyy-mm-dd format
const currentDate = new Date().toISOString().split('T')[0];

Expand Down Expand Up @@ -48,14 +52,93 @@ namespace France {
};
}

namespace ElectricityMaps {
// Open Data Soft base and API URLs
const EMAPS_API_BASEURL = 'https://api.electricitymap.org';
const EMAPS_API_PATH = '/v3/carbon-intensity/latest';

/**
* Settings for making requests to the server.
*/
const SERVER_CONNECTION_SETTINGS = ServerConnection.makeSettings();

export const getElectricityMapsEmissionFactor = async (
proxy: boolean,
accessToken: string,
countryCode: string
): Promise<number> => {
// Make query params into a object
const queryParams = {
zone: countryCode.toUpperCase(),
};

// Convert queryParams into encoded string
const queryString = URLExt.objectToQueryString(queryParams);

// Make request and get response data
try {
let apiUrl: string;

const requestHeaders: HeadersInit = new Headers();
// Set auth token if it is not empty
if (accessToken.length > 0) {
requestHeaders.set('auth-token', accessToken);
}

if (proxy) {
// Make a proxy request to electricty maps from jupyter server
apiUrl = URLExt.join(
SERVER_CONNECTION_SETTINGS.baseUrl,
'api/metrics/v1/emission_factor/emaps',
EMAPS_API_PATH,
queryString
);
} else {
// Make full API URL for making direct request from browser
apiUrl = URLExt.join(EMAPS_API_BASEURL, EMAPS_API_PATH, queryString);
}

// Make request
const response = await fetch(apiUrl, {
method: 'GET',
headers: requestHeaders,
});

if (!response.ok) {
console.debug('Request to Electricity Maps API failed');
return null;
}
const data = await response.json();
if (data && data.carbonIntensity) {
return data.carbonIntensity || 0;
}
} catch (error) {
console.info(`Request to Electricity Maps failed due to ${error}`);
return null;
}
return null;
};
}

/**
* Get eCo2 Emissions coefficient in g/kWh for a given country
*
* @param countryCode ISO code of the country e.g. fr, uk, us, de.
* @param countryCode ISO code of the country e.g. FR, UK, US, DE.
*/
async function getEmissions(countryCode: string): Promise<number> {
if (countryCode === 'fr') {
return await France.getOpenDataSoftEmissions();
async function getEmissions(
source: string,
proxy: boolean,
accessTokens: EmissionFactor.Model.IAccessTokens,
countryCode: string
): Promise<number> {
if (source === 'rte') {
return await RTE.getOpenDataSoftEmissionFactor();
} else if (source === 'emaps') {
return await ElectricityMaps.getElectricityMapsEmissionFactor(
proxy,
accessTokens.emaps,
countryCode
);
} else {
return null;
}
Expand Down
15 changes: 15 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ const DEFAULT_COUNTRY_CODE = 'fr';
*/
const DEFAULT_EMISSION_FACTOR = 475;

/**
* The default emission factor source
*/
const DEFAULT_EMISSION_FACTOR_SOURCE = 'rte';

/**
* An interface for resource settings.
*/
Expand All @@ -60,6 +65,8 @@ interface IResourceSettings extends JSONObject {
* An interface for emissions settings.
*/
interface IEmissionsSettings extends JSONObject {
source: string;
emapsAccessToken: string;
countryCode: string;
refreshRate: number;
factor: number;
Expand All @@ -84,6 +91,8 @@ const extension: JupyterFrontEndPlugin<void> = {
let cpuPowerLabel = DEFAULT_CPU_POWER_LABEL;
let gpuPowerLabel = DEFAULT_GPU_POWER_LABEL;
let emissionsRefreshRate = DEFAULT_EMISSIONS_REFRESH_RATE;
let emissionFactorSource = DEFAULT_EMISSION_FACTOR_SOURCE;
let emapsAccessToken = '';
let countryCode = DEFAULT_COUNTRY_CODE;
let emissionFactor = DEFAULT_EMISSION_FACTOR;

Expand All @@ -98,6 +107,8 @@ const extension: JupyterFrontEndPlugin<void> = {
}
const emissionsSettings = settings.get('emissions')
.composite as IEmissionsSettings;
emissionFactorSource = emissionsSettings.source;
emapsAccessToken = emissionsSettings.emapsAccessToken;
countryCode = emissionsSettings.countryCode;
emissionsRefreshRate = emissionsSettings.refreshRate;
emissionFactor = emissionsSettings.factor;
Expand All @@ -115,6 +126,10 @@ const extension: JupyterFrontEndPlugin<void> = {

const emissionsModel = new EmissionFactor.Model({
refreshRate: emissionsRefreshRate,
emissionFactorSource: emissionFactorSource,
accessTokens: {
emaps: emapsAccessToken,
},
countryCode: countryCode,
defaultEmissionFactor: emissionFactor,
});
Expand Down
Loading
Loading