Skip to content
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,6 @@ __marimo__/
# Django stuff
staticfiles/
static/admin/

# Mac stuff
.DS_Store
1 change: 1 addition & 0 deletions app/logfire.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logfire

from app.configuration.app import app_settings


Expand Down
6 changes: 4 additions & 2 deletions app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
https://docs.djangoproject.com/en/5.2/ref/settings/
"""

import logfire
from pathlib import Path

import logfire

from app.configuration.app import app_settings
from app.configuration.django import django_settings
from app.configuration.postgres import postgres_settings
from app.configuration.app import app_settings

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
Expand Down
2 changes: 1 addition & 1 deletion app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from django.contrib import admin
from django.urls import path

from viz import views, views_plant_status, views_grid_analyser, views_system_headroom
from viz import views, views_grid_analyser, views_plant_status, views_system_headroom

urlpatterns = [
path("admin/", admin.site.urls),
Expand Down
8 changes: 7 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ services:
image: redis:8.2
ports:
- 6379:6379
redis-insight:
image: redis/redisinsight:latest
restart: always
ports:
- "5540:5540"
volumes:
- redis_data:/data
- redis-insight:/data

volumes:
postgres_data:
redis_data:
redis-insight:
1 change: 1 addition & 0 deletions etl/admin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.contrib import admin

from etl.models import Metric, Plant, TimeSeriesData


Expand Down
2 changes: 1 addition & 1 deletion etl/celery_pipeline/celery.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from celery import Celery
from app.configuration.celery import celery_settings

from app.configuration.celery import celery_settings

etl_app = Celery(
main="GridSight",
Expand Down
3 changes: 2 additions & 1 deletion etl/celery_pipeline/live_plants/tasks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from etl.celery_pipeline.celery import etl_app as app
import logfire

from etl.celery_pipeline.celery import etl_app as app


@app.task(bind=True, ignore_result=True)
def ingest_plants_data(self):
Expand Down
3 changes: 2 additions & 1 deletion etl/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.utils import timezone

from etl.models import Metric, Plant, TimeSeriesData
from utils import get_settlement_period


class BMRSDataService:
Expand Down Expand Up @@ -34,7 +35,7 @@ def get_latest_plant_data(self):
"""
now = timezone.now()
settlement_date = now.strftime("%Y-%m-%d")
period = (now.hour * 2) + (1 if now.minute >= 30 else 0) + 1
period = get_settlement_period(now)

mel_data = self._make_api_call(
endpoint="/balancing/physical/all",
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ ipdb
ruff
pytest
pytest-django
pyright
17 changes: 8 additions & 9 deletions templates/plant_status_board.html
Original file line number Diff line number Diff line change
Expand Up @@ -102,31 +102,30 @@ <h2 class="accordion-header">
<div class="card plant-tile shadow-sm h-100 {{ plant.status_class }}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<span class="plant-name">{{ plant.name }}</span>
<span class="plant-name">{{ plant.plant_status.core_status.name }}</span>
<span class="badge rounded-pill {{ plant.badge_class }} plant-status">
{% if plant.icon %}<i class="bi {{ plant.icon }}"></i> {% endif %}{{ plant.status_text }}
</span>
</div>

{% if plant.status_text == 'Accepted Bid' %}
<div class="text-center my-2">
<div class="offer-price">£{{ plant.offer_price }}</div>
<div class="start-time">Starts at: {{ plant.start_time }}</div>
<div class="offer-price">£{{ plant.plant_status.bid_accepted_status.offer_price }}</div>
<div class="start-time">Starts at: {{ plant.plant_status.bid_accepted_status.start_time }}</div>
</div>
{% else %}
{% elif plant.status_text == 'Balancing' %}
<div class="text-center my-2">
<span class="generation-value">
{{ plant.actual_gen }}
{% if plant.balancing_direction == 'up' %}<i class="bi bi-arrow-up text-success"></i>
{% elif plant.balancing_direction == 'down' %}<i class="bi bi-arrow-down text-danger"></i>
{% if plant.plant_status.balancing_status.balancing_direction == 'up' %}<i class="bi bi-arrow-up text-success"></i>
{% elif plant.plant_status.balancing_status.balancing_direction == 'down' %}<i class="bi bi-arrow-down text-danger"></i>
{% endif %}
</span>
<span class="generation-plan">/ {{ plant.fpn }} MW</span>
<span class="generation-plan">/ {{ plant.plant_status.core_status.fpn }} MW</span>
</div>
{% endif %}

<div class="text-center text-muted">
<span class="mel-value">MEL: {{ plant.mel }} MW</span>
<span class="mel-value">MEL: {{ plant.plant_status.core_status.mel }} MW</span>
</div>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions tests/app/test_logfire.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest

from app.logfire import Logfire


Expand Down
128 changes: 128 additions & 0 deletions todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,131 @@ Product:

Shared app setup/infra:
- Add healthcheck Celery task


first bingo baord /live plant -
middle headroom -
spark spread (volatility analyser) is what we call the first - Jordan


We can have different approaches

Celery tasks to begin with
Only as much testing as needed
ELEXON API is a free API, we just need a key and there is still limit rates


The view (viz/views_plant_status.py) and template (templates/plant_status_board.html)
for the "bingo board" are built but use a hardcoded list of Python dictionaries for data.

The BMRSDataService in etl/services.py already fetches and stores the MEL (Maximum Export Limit)
and FPN (Final Physical Notification) data into the TimeSeriesData model. You can start by querying
this model to populate the main generation figures for each plant.

The current ETL does not yet fetch balancing mechanism data (for "Balancing" status) or Bid-Offer Acceptances (for "Accepted Bid" status). The next step after integrating existing data will be to expand the BMRSDataService to fetch from the BOALF dataset mentioned in docs/data-sources.md


# plants_data = [
# PlantStatusCard(
# name="T_DRAX-1",
# status_class=StatusClass.GENERATING,
# badge_class=BadgeClass.PRIMARY,
# status_text=StatusText.GENERATING,
# icon=None,
# actual_gen=640,
# fpn=640,
# mel=645,
# balancing_direction=None,
# ),
# PlantStatusCard(
# name="T_PEMB-21",
# status_class=StatusClass.BALANCING,
# badge_class=BadgeClass.WARNING,
# status_text=StatusText.BALANCING,
# icon=None,
# actual_gen=450,
# fpn=400,
# mel=510,
# balancing_direction=BalancingDirection.UP,
# ),
# PlantStatusCard(
# name="T_STAY-1",
# status_class=StatusClass.STANDBY,
# badge_class=BadgeClass.SUCCESS,
# status_text=StatusText.STANDBY,
# icon=None,
# actual_gen=0,
# fpn=0,
# mel=350,
# balancing_direction=None,
# ),
# PlantStatusCard(
# name="T_WBURB-1",
# status_class=StatusClass.TRIP,
# badge_class=BadgeClass.DANGER,
# status_text=StatusText.TRIP,
# icon="bi-exclamation-triangle-fill",
# actual_gen=0,
# fpn=450,
# mel=0,
# balancing_direction=None,
# ),
# PlantStatusCard(
# name="T_EGGPS-1",
# status_class=StatusClass.OUTAGE,
# badge_class=BadgeClass.SECONDARY,
# status_text=StatusText.OUTAGE,
# icon=None,
# actual_gen=0,
# fpn=0,
# mel=0,
# balancing_direction=None,
# ),
# PlantStatusCard(
# name="T_GRAIN-1",
# status_class=StatusClass.GENERATING,
# badge_class=BadgeClass.PRIMARY,
# status_text=StatusText.GENERATING,
# icon=None,
# actual_gen=420,
# fpn=420,
# mel=420,
# balancing_direction=None,
# ),
# PlantStatusCard(
# name="T_CORBY-1",
# status_class=StatusClass.STANDBY,
# badge_class=BadgeClass.SUCCESS,
# status_text=StatusText.STANDBY,
# icon=None,
# actual_gen=0,
# fpn=0,
# mel=180,
# balancing_direction=None,
# ),
# PlantStatusCard(
# name="T_HARTL-1",
# status_class=StatusClass.GENERATING,
# badge_class=BadgeClass.PRIMARY,
# status_text=StatusText.GENERATING,
# icon=None,
# actual_gen=595,
# fpn=595,
# mel=595,
# balancing_direction=None,
# ),
# # This next one represents a plant in the "Accepted Bid" state and requires new keys: offer_price and start_time
# PlantStatusCard(
# name="T_FFES-4",
# status_class=StatusClass.ACCEPTED_BID,
# badge_class=BadgeClass.INFO,
# status_text=StatusText.ACCEPTED_BID,
# icon="bi-hourglass-split",
# actual_gen=0,
# fpn=0,
# mel=60,
# balancing_direction=None,
# offer_price=115,
# start_time="19:00",
# ),
# ]
33 changes: 33 additions & 0 deletions utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from datetime import datetime


def get_settlement_period(timestamp: datetime) -> int:
# 2 settlement periods per hour, 1-indexed
return (timestamp.hour * 2) + (1 if timestamp.minute >= 30 else 0) + 1


def get_start_and_end_of_settlement_period(
timestamp: datetime,
) -> tuple[datetime, datetime]:
"""
Returns the start and end timestamps for the settlement period containing the given datetime.
Settlement periods are 30-minute blocks.

Args:
timestamp: A datetime object

Returns:
A tuple of (datetime, datetime) for the settlement period
"""
# Determine if we're in the first half (00-29 mins) or second half (30-59 mins) of the hour
if timestamp.minute < 30:
start_minute = 0
end_minute = 29
else:
start_minute = 30
end_minute = 59

start = timestamp.replace(minute=start_minute, second=0, microsecond=0)
end = timestamp.replace(minute=end_minute, second=59, microsecond=999999)

return start, end
11 changes: 0 additions & 11 deletions viz/plant_status/data_models.py
Original file line number Diff line number Diff line change
@@ -1,11 +0,0 @@
from enum import StrEnum


class FuelType(StrEnum):
COAL = "coal"
GAS = "gas"


class BalancingDirection(StrEnum):
UP = "up"
DOWN = "down"
36 changes: 16 additions & 20 deletions viz/plant_status/presentation.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
from enum import StrEnum
from pydantic import BaseModel
from decimal import Decimal
from datetime import datetime

from pydantic import BaseModel
from viz.plant_status.status import PlantFullStatus

from etl.models import Plant
from viz.plant_status.status import PlantStatusSolver

from viz.plant_status.data_models import (
BalancingDirection,
)


class Card(BaseModel): ...

Expand Down Expand Up @@ -41,15 +39,14 @@ class StatusText(StrEnum):


class PlantStatusCard(Card):
name: str
# Card status
status_class: StatusClass
badge_class: BadgeClass
status_text: StatusText
icon: str | None
actual_gen: Decimal
fpn: Decimal
mel: Decimal
balancing_direction: BalancingDirection | None
icon: str | None = None

# Domain-specific plant status
plant_status: PlantFullStatus


class PlantStatusViewContext(BaseModel):
Expand Down Expand Up @@ -80,16 +77,15 @@ def resolve(
plant: Plant,
) -> PlantStatusCard:
plant_status = PlantStatusSolver.resolve(plant)
plant_state_value = plant_status.core_status.state.value
plant_status_class = StatusClass[plant_status.core_status.state.value]

return PlantStatusCard(
name=plant.name,
status_class=StatusClass[plant_status.state.value],
status_class=plant_status_class,
badge_class=PlantStatusCardResolver._resolve_badge_class(
StatusClass[plant_status.state.value]
plant_status_class
),
status_text=StatusText[plant_status.state.value],
status_text=StatusText[plant_state_value],
icon=None,
fpn=plant_status.fpn,
mel=plant_status.mel,
balancing_direction=plant_status.balancing_direction,
actual_gen=plant_status.current_generation,
plant_status=plant_status,
)
Loading