feat: carbon intensity segment

This commit is contained in:
Alex Potter 2023-10-28 10:02:17 +01:00 committed by Jan De Dobbeleer
parent 092a0f9bcd
commit 5655bb4e6d
6 changed files with 615 additions and 79 deletions

View file

@ -108,6 +108,8 @@ const (
BREWFATHER SegmentType = "brewfather" BREWFATHER SegmentType = "brewfather"
// Buf segment writes the active buf version // Buf segment writes the active buf version
BUF SegmentType = "buf" BUF SegmentType = "buf"
// CARBONINTENSITY writes the actual and forecast carbon intensity in gCO2/kWh
CARBONINTENSITY SegmentType = "carbonintensity"
// cds (SAP CAP) version // cds (SAP CAP) version
CDS SegmentType = "cds" CDS SegmentType = "cds"
// Cloud Foundry segment // Cloud Foundry segment
@ -263,6 +265,7 @@ var Segments = map[SegmentType]func() SegmentWriter{
BATTERY: func() SegmentWriter { return &segments.Battery{} }, BATTERY: func() SegmentWriter { return &segments.Battery{} },
BREWFATHER: func() SegmentWriter { return &segments.Brewfather{} }, BREWFATHER: func() SegmentWriter { return &segments.Brewfather{} },
BUF: func() SegmentWriter { return &segments.Buf{} }, BUF: func() SegmentWriter { return &segments.Buf{} },
CARBONINTENSITY: func() SegmentWriter { return &segments.CarbonIntensity{} },
CDS: func() SegmentWriter { return &segments.Cds{} }, CDS: func() SegmentWriter { return &segments.Cds{} },
CF: func() SegmentWriter { return &segments.Cf{} }, CF: func() SegmentWriter { return &segments.Cf{} },
CFTARGET: func() SegmentWriter { return &segments.CfTarget{} }, CFTARGET: func() SegmentWriter { return &segments.CfTarget{} },

View file

@ -0,0 +1,155 @@
package segments
import (
"encoding/json"
"fmt"
"github.com/jandedobbeleer/oh-my-posh/src/platform"
"github.com/jandedobbeleer/oh-my-posh/src/properties"
)
type CarbonIntensity struct {
props properties.Properties
env platform.Environment
TrendIcon string
CarbonIntensityData
}
type CarbonIntensityResponse struct {
Data []CarbonIntensityPeriod `json:"data"`
}
type CarbonIntensityPeriod struct {
From string `json:"from"`
To string `json:"to"`
Intensity *CarbonIntensityData `json:"intensity"`
}
type CarbonIntensityData struct {
Forecast Number `json:"forecast"`
Actual Number `json:"actual"`
Index Index `json:"index"`
}
type Number int
func (n Number) String() string {
if n == 0 {
return "??"
}
return fmt.Sprintf("%d", n)
}
type Index string
func (i Index) Icon() string {
switch i {
case "very low":
return "↓↓"
case "low":
return "↓"
case "moderate":
return "•"
case "high":
return "↑"
case "very high":
return "↑↑"
default:
return ""
}
}
func (d *CarbonIntensity) Enabled() bool {
err := d.setStatus()
if err != nil {
d.env.Error(err)
return false
}
return true
}
func (d *CarbonIntensity) Template() string {
return " CO₂ {{ .Index.Icon }}{{ .Actual.String }} {{ .TrendIcon }} {{ .Forecast.String }} "
}
func (d *CarbonIntensity) updateCache(responseBody []byte, url string, cacheTimeoutInMinutes int) {
if cacheTimeoutInMinutes > 0 {
d.env.Cache().Set(url, string(responseBody), cacheTimeoutInMinutes)
}
}
func (d *CarbonIntensity) getResult() (*CarbonIntensityResponse, error) {
cacheTimeoutInMinutes := d.props.GetInt(properties.CacheTimeout, properties.DefaultCacheTimeout)
response := new(CarbonIntensityResponse)
url := "https://api.carbonintensity.org.uk/intensity"
if cacheTimeoutInMinutes > 0 {
cachedValue, foundInCache := d.env.Cache().Get(url)
if foundInCache {
err := json.Unmarshal([]byte(cachedValue), response)
if err == nil {
return response, nil
}
// If there was an error, just fall through to refetching
}
}
httpTimeout := d.props.GetInt(properties.HTTPTimeout, properties.DefaultHTTPTimeout)
body, err := d.env.HTTPRequest(url, nil, httpTimeout)
if err != nil {
d.updateCache(body, url, cacheTimeoutInMinutes)
return new(CarbonIntensityResponse), err
}
err = json.Unmarshal(body, &response)
if err != nil {
d.updateCache(body, url, cacheTimeoutInMinutes)
return new(CarbonIntensityResponse), err
}
return response, nil
}
func (d *CarbonIntensity) setStatus() error {
response, err := d.getResult()
if err != nil {
return err
}
if len(response.Data) == 0 {
d.Actual = 0
d.Forecast = 0
d.Index = "??"
d.TrendIcon = "→"
return nil
}
d.CarbonIntensityData = *response.Data[0].Intensity
if d.Forecast > d.Actual {
d.TrendIcon = "↗"
}
if d.Forecast < d.Actual {
d.TrendIcon = "↘"
}
if d.Forecast == d.Actual || d.Actual == 0 || d.Forecast == 0 {
d.TrendIcon = "→"
}
return nil
}
func (d *CarbonIntensity) Init(props properties.Properties, env platform.Environment) {
d.props = props
d.env = env
}

View file

@ -0,0 +1,266 @@
package segments
import (
"errors"
"fmt"
"testing"
"github.com/jandedobbeleer/oh-my-posh/src/mock"
"github.com/jandedobbeleer/oh-my-posh/src/properties"
"github.com/stretchr/testify/assert"
mock2 "github.com/stretchr/testify/mock"
)
const (
CARBONINTENSITYURL = "https://api.carbonintensity.org.uk/intensity"
)
func TestCarbonIntensitySegmentSingle(t *testing.T) {
cases := []struct {
Case string
HasError bool
HasData bool
Actual int
Forecast int
Index string
ExpectedString string
ExpectedEnabled bool
Template string
}{
{
Case: "Very Low, Going Down",
HasError: false,
HasData: true,
Actual: 20,
Forecast: 10,
Index: "very low",
ExpectedString: "CO₂ ↓↓20 ↘ 10",
ExpectedEnabled: true,
},
{
Case: "Very Low, Staying Same",
HasError: false,
HasData: true,
Actual: 20,
Forecast: 20,
Index: "very low",
ExpectedString: "CO₂ ↓↓20 → 20",
ExpectedEnabled: true,
},
{
Case: "Very Low, Going Up",
HasError: false,
HasData: true,
Actual: 20,
Forecast: 30,
Index: "very low",
ExpectedString: "CO₂ ↓↓20 ↗ 30",
ExpectedEnabled: true,
},
{
Case: "Low, Going Down",
HasError: false,
HasData: true,
Actual: 100,
Forecast: 50,
Index: "low",
ExpectedString: "CO₂ ↓100 ↘ 50",
ExpectedEnabled: true,
},
{
Case: "Low, Staying Same",
HasError: false,
HasData: true,
Actual: 100,
Forecast: 100,
Index: "low",
ExpectedString: "CO₂ ↓100 → 100",
ExpectedEnabled: true,
},
{
Case: "Low, Going Up",
HasError: false,
HasData: true,
Actual: 100,
Forecast: 150,
Index: "low",
ExpectedString: "CO₂ ↓100 ↗ 150",
ExpectedEnabled: true,
},
{
Case: "Moderate, Going Down",
HasError: false,
HasData: true,
Actual: 150,
Forecast: 100,
Index: "moderate",
ExpectedString: "CO₂ •150 ↘ 100",
ExpectedEnabled: true,
},
{
Case: "Moderate, Staying Same",
HasError: false,
HasData: true,
Actual: 150,
Forecast: 150,
Index: "moderate",
ExpectedString: "CO₂ •150 → 150",
ExpectedEnabled: true,
},
{
Case: "Moderate, Going Up",
HasError: false,
HasData: true,
Actual: 150,
Forecast: 200,
Index: "moderate",
ExpectedString: "CO₂ •150 ↗ 200",
ExpectedEnabled: true,
},
{
Case: "High, Going Down",
HasError: false,
HasData: true,
Actual: 200,
Forecast: 150,
Index: "high",
ExpectedString: "CO₂ ↑200 ↘ 150",
ExpectedEnabled: true,
},
{
Case: "High, Staying Same",
HasError: false,
HasData: true,
Actual: 200,
Forecast: 200,
Index: "high",
ExpectedString: "CO₂ ↑200 → 200",
ExpectedEnabled: true,
},
{
Case: "High, Going Up",
HasError: false,
HasData: true,
Actual: 200,
Forecast: 300,
Index: "high",
ExpectedString: "CO₂ ↑200 ↗ 300",
ExpectedEnabled: true,
},
{
Case: "Missing Actual",
HasError: false,
HasData: true,
Actual: 0, // Missing data will be parsed to the default value of 0
Forecast: 300,
Index: "high",
ExpectedString: "CO₂ ↑?? → 300",
ExpectedEnabled: true,
},
{
Case: "Missing Forecast",
HasError: false,
HasData: true,
Actual: 200,
Forecast: 0, // Missing data will be parsed to the default value of 0
Index: "high",
ExpectedString: "CO₂ ↑200 → ??",
ExpectedEnabled: true,
},
{
Case: "Missing Index",
HasError: false,
HasData: true,
Actual: 200,
Forecast: 300,
Index: "", // Missing data will be parsed to the default value of ""
ExpectedString: "CO₂ 200 ↗ 300",
ExpectedEnabled: true,
},
{
Case: "Missing Data",
HasError: false,
HasData: false,
Actual: 0,
Forecast: 0,
Index: "",
ExpectedString: "CO₂ ?? → ??",
ExpectedEnabled: true,
},
{
Case: "Error",
HasError: true,
HasData: false,
Actual: 0,
Forecast: 0,
Index: "",
ExpectedString: "",
ExpectedEnabled: false,
},
}
for _, tc := range cases {
env := &mock.MockedEnvironment{}
var props = properties.Map{
properties.HTTPTimeout: 5000,
properties.CacheTimeout: 0,
}
jsonResponse := fmt.Sprintf(
`{ "data": [ { "from": "2023-10-27T12:30Z", "to": "2023-10-27T13:00Z", "intensity": { "forecast": %d, "actual": %d, "index": "%s" } } ] }`,
tc.Forecast, tc.Actual, tc.Index,
)
if !tc.HasData {
jsonResponse = `{ "data": [] }`
}
if tc.HasError {
jsonResponse = `{ "error": "Something went wrong" }`
}
responseError := errors.New("Something went wrong")
if !tc.HasError {
responseError = nil
}
env.On("HTTPRequest", CARBONINTENSITYURL).Return([]byte(jsonResponse), responseError)
env.On("Error", mock2.Anything)
d := &CarbonIntensity{
props: props,
env: env,
}
enabled := d.Enabled()
assert.Equal(t, tc.ExpectedEnabled, enabled, tc.Case)
if !enabled {
continue
}
if tc.Template == "" {
tc.Template = d.Template()
}
assert.Equal(t, tc.ExpectedString, renderTemplate(env, tc.Template, d), tc.Case)
}
}
func TestCarbonIntensitySegmentFromCache(t *testing.T) {
response := `{ "data": [ { "from": "2023-10-27T12:30Z", "to": "2023-10-27T13:00Z", "intensity": { "forecast": 199, "actual": 193, "index": "moderate" } } ] }`
expectedString := "CO₂ •193 ↗ 199"
env := &mock.MockedEnvironment{}
cache := &mock.MockedCache{}
d := &CarbonIntensity{
props: properties.Map{},
env: env,
}
cache.On("Get", CARBONINTENSITYURL).Return(response, true)
cache.On("Set").Return()
env.On("Cache").Return(cache)
assert.Nil(t, d.setStatus())
assert.Equal(t, expectedString, renderTemplate(env, d.Template(), d), "should return the cached response")
}

View file

@ -254,6 +254,7 @@
"azfunc", "azfunc",
"angular", "angular",
"battery", "battery",
"carbonintensity",
"command", "command",
"connection", "connection",
"crystal", "crystal",
@ -608,6 +609,31 @@
} }
} }
}, },
{
"if": {
"properties": {
"type": {
"const": "carbonintensity"
}
}
},
"then": {
"title": "Carbon Intensity Segment",
"description": "Displays the actual and forecast carbon intensity in gCO2/kWh using the Carbon Intensity API",
"properties": {
"properties": {
"properties": {
"http_timeout": {
"$ref": "#/definitions/http_timeout"
},
"cache_timeout": {
"$ref": "#/definitions/cache_timeout"
}
}
}
}
}
},
{ {
"if": { "if": {
"properties": { "properties": {

View file

@ -0,0 +1,85 @@
---
id: carbonintensity
title: Carbon Intensity
sidebar_label: Carbon Intensity
---
## What
Shows the actual and forecast carbon intensity in gCO2/kWh using data from the [Carbon Intensity API][carbonintensity-api].
:::note
Note that this segment only provides data for Great Britain at the moment. Support for other countries may become available in the future.
:::
## Sample Configuration
:::caution
The API can be slow. It's recommended to set the `http_timeout` property to a large value (e.g. `5000`).
:::
import Config from "@site/src/components/Config.js";
<Config
data={{
type: "carbonintensity",
style: "powerline",
powerline_symbol: "\uE0B0",
foreground: "#000000",
background: "#ffffff",
background_templates: [
'{{if eq "very low" .Index}}#a3e635{{end}}',
'{{if eq "low" .Index}}#bef264{{end}}',
'{{if eq "moderate" .Index}}#fbbf24{{end}}',
'{{if eq "high" .Index}}#ef4444{{end}}',
'{{if eq "very high" .Index}}#dc2626{{end}}',
],
template:
" CO₂ {{ .Index.Icon }}{{ .Actual.String }} {{ .TrendIcon }} {{ .Forecast.String }} ",
properties: {
http_timeout: 5000,
cache_timeout: 10,
},
}}
/>
## Properties
| Name | Type | Description |
| --------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `http_timeout` | `int` | Timeout (in milliseconds) for HTTP requests. The default is 20ms, but you may need to set this to as high as 5000ms to handle slow API requests. |
| `cache_timeout` | `int` | Timeout (in minutes) for the response cache. The default is 10m. A value of 0 disables the cache. |
## Template ([info][templates])
:::note default template
```template
CO₂ {{ .Index.Icon }}{{ .Actual.String }} {{ .TrendIcon }} {{ .Forecast.String }}
```
:::
### Properties
| Name | Type | Description |
| ------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.Forecast` | `Number` | The forecast carbon intensity in gCO2/kWh. Equal to `0` if no data is available. |
| `.Actual` | `Number` | The actual carbon intensity in gCO2/kWh. Equal to `0` if no data is available. |
| `.Index` | `Index` | A rating of the current carbon intensity. Possible values are `"very low"`, `"low"`, `"moderate"`, `"high"`, or `"very high"`. Equal to `"??"` if no data is available. |
| `.TrendIcon` | `string` | An icon representation of the predicted trend in carbon intensity based on the Actual and Forecast values. Possible values are `"↗"`, `"↘"`, or `"→"`. |
#### Number
| Name | Type | Description |
| --------- | -------- | ---------------------------------- |
| `.String` | `string` | string representation of the value |
#### Index
| Name | Type | Description |
| ------- | -------- | -------------------------------- |
| `.Icon` | `string` | icon representation of the value |
[templates]: /docs/configuration/templates
[carbonintensity-api]: https://carbon-intensity.github.io/api-definitions

View file

@ -59,6 +59,7 @@ module.exports = {
"segments/battery", "segments/battery",
"segments/brewfather", "segments/brewfather",
"segments/buf", "segments/buf",
"segments/carbonintensity",
"segments/cds", "segments/cds",
"segments/cf", "segments/cf",
"segments/cftarget", "segments/cftarget",