diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 99fcdd5..7bc771f 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -2,7 +2,7 @@ name: Deploy atlarge-research.github.io/opendt on: push: - branches: ["master", "refactor-services"] + branches: ["master", "add-documentation"] workflow_dispatch: concurrency: diff --git a/Makefile b/Makefile index 3c4c0aa..83592f7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: up down clean-volumes restart build logs help run test setup install-dev clean-env experiment experiment-down +.PHONY: up down clean-volumes help test setup clean-env # Default target .DEFAULT_GOAL := help @@ -20,210 +20,162 @@ PYTHON := $(VENV)/bin/python PYTEST := $(VENV)/bin/pytest UV := $(shell command -v uv 2> /dev/null) -## up: Stop containers, delete volumes (clean slate), and start fresh (use build=true to rebuild images) +# ============================================================================= +# Core Commands +# ============================================================================= + +## up: Start OpenDT services (use build=true to rebuild images) up: clean-volumes - @echo "๐Ÿš€ Starting OpenDT services with clean slate..." - @echo "๐Ÿ“‹ Using config: $(config)" + @echo "" + @echo "Starting OpenDT..." + @echo "Config: $(config)" + @echo "" @if [ ! -f "$(config)" ]; then \ - echo "โŒ Error: Config file not found: $(config)"; \ + echo "Error: Config file not found: $(config)"; \ exit 1; \ fi - @echo "๐Ÿ”ง Initializing run..." @$(PYTHON) scripts/opendt_cli.py init --config $(config) @RUN_ID=$$(cat .run_id) && \ if [ ! -f "data/$$RUN_ID/.env" ]; then \ - echo "โŒ Error: data/$$RUN_ID/.env not found after initialization"; \ + echo "Error: data/$$RUN_ID/.env not found after initialization"; \ exit 1; \ fi && \ set -a && . ./data/$$RUN_ID/.env && set +a && \ if [ "$(build)" = "true" ]; then \ - echo "๐Ÿ”จ Rebuilding Docker images (no cache)..."; \ + echo "Rebuilding Docker images..."; \ docker compose $$PROFILE_FLAG build --no-cache; \ fi && \ + echo "Starting containers..." && \ docker compose $$PROFILE_FLAG up -d - @echo "โœ… Services started!" @echo "" - @echo "Available services:" - @echo " - Dashboard: http://localhost:8000" - @echo " - API Docs: http://localhost:8000/docs" - @echo " - Postgres: localhost:5432" - @echo " - Kafka: localhost:9092" + @echo "Services started:" + @echo " Dashboard http://localhost:3000" + @echo " API http://localhost:3001" + @echo "" + @echo "View logs with: make logs-" @echo "" - @echo "View logs: make logs" - -## run: Alias for 'up' (accepts config parameter) -run: up ## down: Stop all containers down: - @echo "โน๏ธ Stopping OpenDT services..." + @echo "" + @echo "Stopping OpenDT services..." @RUN_ID=$$(cat .run_id 2>/dev/null || true); \ if [ -n "$$RUN_ID" ] && [ -f "data/$$RUN_ID/.env" ]; then \ set -a && . ./data/$$RUN_ID/.env && set +a && docker compose $$PROFILE_FLAG down; \ else \ docker compose down; \ fi - @echo "โœ… Services stopped!" + @echo "Done." + @echo "" -## clean-volumes: Stop containers and delete persistent volumes (Kafka & Postgres) +## clean-volumes: Stop containers and delete all persistent volumes clean-volumes: - @echo "๐Ÿงน Stopping containers and cleaning persistent volumes..." - @RUN_ID=$$(cat .run_id 2>/dev/null || true); \ - if [ -n "$$RUN_ID" ] && [ -f "data/$$RUN_ID/.env" ]; then \ - set -a && . ./data/$$RUN_ID/.env && set +a && docker compose $$PROFILE_FLAG down -v; \ - else \ - docker compose down -v; \ - fi - @echo "๐Ÿ—‘๏ธ Removing named volumes..." - -docker volume rm opendt-postgres-data 2>/dev/null || true - -docker volume rm opendt-kafka-data 2>/dev/null || true - @echo "โœ… Clean slate ready!" - -## restart: Restart all services (without cleaning volumes) -restart: - @echo "โ™ป๏ธ Restarting OpenDT services..." - @RUN_ID=$$(cat .run_id 2>/dev/null || true); \ - if [ -n "$$RUN_ID" ] && [ -f "data/$$RUN_ID/.env" ]; then \ - set -a && . ./data/$$RUN_ID/.env && set +a && docker compose $$PROFILE_FLAG restart; \ - else \ - echo "โš ๏ธ Run environment not found, restarting without profile"; \ - docker compose restart; \ - fi - @echo "โœ… Services restarted!" - -## build: Rebuild all Docker images -build: - @echo "๐Ÿ”จ Building Docker images..." @RUN_ID=$$(cat .run_id 2>/dev/null || true); \ if [ -n "$$RUN_ID" ] && [ -f "data/$$RUN_ID/.env" ]; then \ - set -a && . ./data/$$RUN_ID/.env && set +a && docker compose $$PROFILE_FLAG build --no-cache; \ + set -a && . ./data/$$RUN_ID/.env && set +a && docker compose $$PROFILE_FLAG down -v 2>/dev/null || true; \ else \ - echo "โš ๏ธ Run environment not found, building without profile"; \ - docker compose build --no-cache; \ + docker compose down -v 2>/dev/null || true; \ fi - @echo "โœ… Images built!" + @docker volume rm opendt-kafka-data 2>/dev/null || true + @docker volume rm opendt-grafana-storage 2>/dev/null || true -## rebuild: Clean, rebuild (no cache), and start (alias for make up build=true) -rebuild: - @$(MAKE) up build=true +# ============================================================================= +# Development Commands +# ============================================================================= -## setup: Create virtual environment and install all dependencies +## setup: Create virtual environment and install dependencies setup: - @echo "๐Ÿ”ง Setting up development environment..." + @echo "" + @echo "Setting up development environment..." @if [ -z "$(UV)" ]; then \ - echo "โŒ uv not found. Install it with: curl -LsSf https://astral.sh/uv/install.sh | sh"; \ + echo "Error: uv not found. Install with: curl -LsSf https://astral.sh/uv/install.sh | sh"; \ exit 1; \ fi - @echo "Creating virtual environment with uv..." - uv venv + @echo "Creating virtual environment..." + @uv venv @echo "Installing dependencies..." - uv pip install -e libs/common - uv pip install -e "libs/common[test]" - uv pip install -e ".[dev]" - @echo "โœ… Development environment ready!" + @uv pip install -e libs/common + @uv pip install -e "libs/common[test]" + @uv pip install -e ".[dev]" + @echo "" + @echo "Done. Activate with: source .venv/bin/activate" @echo "" - @echo "Activate with: source .venv/bin/activate" - -## install-dev: Install dependencies in existing venv (for CI or manual setup) -install-dev: - @echo "๐Ÿ“ฆ Installing development dependencies..." - @if [ ! -d "$(VENV)" ]; then \ - echo "โŒ Virtual environment not found. Run 'make setup' first."; \ - exit 1; \ - fi - $(PYTHON) -m pip install -e libs/common - $(PYTHON) -m pip install -e "libs/common[test]" - $(PYTHON) -m pip install -e ".[dev]" - @echo "โœ… Dependencies installed!" -## test: Run tests for shared library +## test: Run tests test: - @echo "๐Ÿงช Running tests..." + @echo "" + @echo "Running tests..." @if [ ! -d "$(VENV)" ]; then \ - echo "Virtual environment not found. Running 'make setup'..."; \ + echo "Virtual environment not found. Running setup..."; \ $(MAKE) setup; \ fi @if [ ! -f "$(PYTEST)" ]; then \ - echo "pytest not found. Running 'make install-dev'..."; \ - $(MAKE) install-dev; \ + echo "pytest not found. Running setup..."; \ + $(MAKE) setup; \ fi - $(PYTEST) libs/common/tests/ -v --tb=short - @echo "โœ… Tests passed!" + @$(PYTEST) libs/common/tests/ -v --tb=short + @echo "" + @echo "All tests passed." + @echo "" ## clean-env: Remove virtual environment clean-env: - @echo "๐Ÿงน Removing virtual environment..." - rm -rf $(VENV) - rm -rf .uv - @echo "โœ… Environment cleaned!" - -## logs: Tail logs for all services -logs: - @RUN_ID=$$(cat .run_id) && set -a && . ./data/$$RUN_ID/.env && set +a && docker compose $$PROFILE_FLAG logs -f + @echo "" + @echo "Removing virtual environment..." + @rm -rf $(VENV) + @rm -rf .uv + @echo "Done." + @echo "" -## logs-dashboard: Tail logs for dashboard service only -logs-dashboard: - @RUN_ID=$$(cat .run_id) && set -a && . ./data/$$RUN_ID/.env && set +a && docker compose logs -f dashboard +# ============================================================================= +# Logging Commands +# ============================================================================= -## logs-kafka: Tail logs for Kafka service only -logs-kafka: - @RUN_ID=$$(cat .run_id) && set -a && . ./data/$$RUN_ID/.env && set +a && docker compose logs -f kafka +## logs-api: Tail logs for api service +logs-api: + @RUN_ID=$$(cat .run_id) && set -a && . ./data/$$RUN_ID/.env && set +a && docker compose logs -f api -## logs-dc-mock: Tail logs for dc-mock service only +## logs-dc-mock: Tail logs for dc-mock service logs-dc-mock: @RUN_ID=$$(cat .run_id) && set -a && . ./data/$$RUN_ID/.env && set +a && docker compose logs -f dc-mock -## logs-simulator: Tail logs for simulator service only +## logs-simulator: Tail logs for simulator service logs-simulator: @RUN_ID=$$(cat .run_id) && set -a && . ./data/$$RUN_ID/.env && set +a && docker compose logs -f simulator -## logs-calibrator: Tail logs for calibrator service only +## logs-calibrator: Tail logs for calibrator service logs-calibrator: @RUN_ID=$$(cat .run_id) && set -a && . ./data/$$RUN_ID/.env && set +a && docker compose --profile calibration logs -f calibrator -## up-with-calibration: Start services including calibrator -# up-with-calibration: clean-volumes -# @echo "๐Ÿš€ Starting OpenDT services with calibration enabled..." -# @echo "๐Ÿ“‹ Using config: $(config)" -# @if [ ! -f "$(config)" ]; then \ -# echo "โŒ Error: Config file not found: $(config)"; \ -# exit 1; \ -# fi -# @if [ "$(build)" = "true" ]; then \ -# echo "๐Ÿ”จ Rebuilding Docker images (no cache)..."; \ -# CONFIG_PATH=$(config) docker compose --profile calibration build --no-cache; \ -# echo "โœ… Images rebuilt!"; \ -# fi -# CONFIG_PATH=$(config) docker compose --profile calibration up -d -# @echo "โœ… Services started with calibration!" -# @echo "" -# @echo "Available services:" -# @echo " - Dashboard: http://localhost:8000" -# @echo " - Calibrator: Automatic power model calibration" -# @echo " - Postgres: localhost:5432" -# @echo " - Kafka: localhost:9092" -# @echo "" -# @echo "View logs: make logs-calibrator" - -## ps: Show running containers -ps: - @RUN_ID=$$(cat .run_id) && set -a && . ./data/$$RUN_ID/.env && set +a && docker compose $$PROFILE_FLAG ps - -## shell-dashboard: Open a shell in the dashboard container -shell-dashboard: - @RUN_ID=$$(cat .run_id) && set -a && . ./data/$$RUN_ID/.env && set +a && docker compose exec dashboard /bin/bash - -## shell-postgres: Open psql in the Postgres container -shell-postgres: - @RUN_ID=$$(cat .run_id) && set -a && . ./data/$$RUN_ID/.env && set +a && docker compose exec postgres psql -U opendt -d opendt - -## kafka-topics: List Kafka topics -kafka-topics: - @RUN_ID=$$(cat .run_id) && set -a && . ./data/$$RUN_ID/.env && set +a && docker compose exec kafka kafka-topics --bootstrap-server localhost:9092 --list - -## help: Show this help message +# ============================================================================= +# Shell Commands +# ============================================================================= + +## shell-api: Open a shell in the api container +shell-api: + @RUN_ID=$$(cat .run_id) && set -a && . ./data/$$RUN_ID/.env && set +a && docker compose exec api /bin/bash + +## shell-dc-mock: Open a shell in the dc-mock container +shell-dc-mock: + @RUN_ID=$$(cat .run_id) && set -a && . ./data/$$RUN_ID/.env && set +a && docker compose exec dc-mock /bin/bash + +## shell-simulator: Open a shell in the simulator container +shell-simulator: + @RUN_ID=$$(cat .run_id) && set -a && . ./data/$$RUN_ID/.env && set +a && docker compose exec simulator /bin/bash + +## shell-calibrator: Open a shell in the calibrator container +shell-calibrator: + @RUN_ID=$$(cat .run_id) && set -a && . ./data/$$RUN_ID/.env && set +a && docker compose --profile calibration exec calibrator /bin/bash + +# ============================================================================= +# Help +# ============================================================================= + +## help: Show available commands help: - @echo "OpenDT Makefile Commands" - @echo "========================" @echo "" - @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' + @echo "OpenDT Commands" + @echo "===============" + @echo "" + @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' + @echo "" diff --git a/README.md b/README.md index 721077c..af83af7 100644 --- a/README.md +++ b/README.md @@ -1,193 +1,96 @@ -# OpenDT - Open Digital Twin for Datacenters + + OpenDT logo + -**OpenDT** is a distributed system for real-time datacenter simulation and What-If analysis. It operates in "Shadow Mode" by replaying historical workload data through the OpenDC simulator to compare predicted vs. actual power consumption. +# OpenDT -[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) -[![Docker](https://img.shields.io/badge/docker-required-blue.svg)](https://www.docker.com/) - -## Quick Start - -### Prerequisites +**Open Digital Twin for Datacenters** -- **Docker & Docker Compose** - Container orchestration -- **Make** - Convenience commands -- **Python 3.11+** - For local development -- **uv** - Python package manager ([install](https://astral.sh/uv/install)) +OpenDT is a distributed system that creates a real-time digital twin of datacenter infrastructure. It replays historical workload data through the [OpenDC](https://opendc.org/) simulator to predict power consumption, enabling What-If analysis without touching live hardware. -### Setup +[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) +[![Docker](https://img.shields.io/badge/docker-required-blue.svg)](https://www.docker.com/) -```bash -# 1. Clone repository -git clone https://github.com/your-org/opendt.git -cd opendt +--- -# 2. Setup environment -make setup +## Quick Start -# 3. Start services -make up +**Prerequisites:** Docker, Docker Compose, Make, Python 3.11+, [uv](https://astral.sh/uv) -# 4. Access services -open http://localhost:8000 # Dashboard ``` - -That's it! The system is now running with the SURF workload dataset. - -### Running an Experiment - -```bash -# Run experiment with custom config -make experiment name=baseline - -# View results -ls output/baseline/run_1/ -# - results.parquet (power predictions) -# - power_plot.png (actual vs simulated) -# - opendc/ (simulation archives) +make setup # Install dependencies +make up # Start services ``` -## Documentation - -### Getting Started +**Access:** +- **Dashboard:** http://localhost:3000 (Grafana) +- **API:** http://localhost:3001 (OpenAPI docs) -- **[Architecture Overview](docs/ARCHITECTURE.md)** - System design, services, and data flow -- **[Data Models](docs/DATA_MODELS.md)** - Pydantic models and data schemas - -### Service Documentation +![OpenDT Grafana Dashboard](site/src/components/HomepageFeatures/grafana-dashboard.png) +*Real-time dashboard comparing actual vs. simulated power consumption* -- **[dc-mock](services/dc-mock/README.md)** - Data producer (workload replay) -- **[sim-worker](services/sim-worker/README.md)** - Simulation engine (OpenDC integration) -- **[dashboard](services/dashboard/README.md)** - Web dashboard and REST API -- **[kafka-init](services/kafka-init/README.md)** - Kafka infrastructure setup +--- -## Architecture +## Repository Structure ``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ dc-mock โ”‚โ”€โ”€โ”€โ”€>โ”‚ Kafka Bus โ”‚ -โ”‚ (Producer) โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ Topics: โ”‚ โ”‚ -โ”‚ Reads: โ”‚ โ”‚ โ”‚ โ€ข dc.workload (tasks) โ”‚ โ”‚ -โ”‚ - tasks โ”‚ โ”‚ โ”‚ โ€ข dc.power (telemetry) โ”‚ โ”‚ -โ”‚ - fragments โ”‚ โ”‚ โ”‚ โ€ข dc.topology (real) โ”‚ โ”‚ -โ”‚ - power โ”‚ โ”‚ โ”‚ โ€ข sim.topology (simulated) โ”‚ โ”‚ -โ”‚ - topology โ”‚ โ”‚ โ”‚ โ€ข sim.results (predictions) โ”‚ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ โ”‚ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ sim-worker โ”‚ โ”‚ dashboard โ”‚ - โ”‚ (Consumer) โ”‚ โ”‚ (FastAPI) โ”‚ - โ”‚ โ”‚ โ”‚ โ”‚ - โ”‚ โ€ข Windows โ”‚ โ”‚ โ€ข Web UI โ”‚ - โ”‚ โ€ข OpenDC โ”‚ โ”‚ โ€ข REST API โ”‚ - โ”‚ โ€ข Caching โ”‚โ—€โ”€โ”€โ”€โ”€โ”‚ โ€ข Topology Mgmt โ”‚ - โ”‚ โ€ข Experiments โ”‚ โ”‚ โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ โ”‚ - โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ โ”‚ PostgreSQL โ”‚ - โ”‚ โ”‚ (TimescaleDB) โ”‚ - โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ Experiment โ”‚ - โ”‚ Output: โ”‚ - โ”‚ โ€ข Parquet โ”‚ - โ”‚ โ€ข Plots โ”‚ - โ”‚ โ€ข Archives โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +opendt/ +โ”œโ”€โ”€ config/ # Configuration files +โ”‚ โ”œโ”€โ”€ default.yaml # Default configuration +โ”‚ โ””โ”€โ”€ experiments/ # Experiment-specific configs +โ”œโ”€โ”€ data/ # Run outputs (timestamped directories) +โ”œโ”€โ”€ docs/ # Documentation +โ”œโ”€โ”€ libs/common/ # Shared library (Pydantic models, utilities) +โ”œโ”€โ”€ opendc/ # OpenDC simulator binary +โ”œโ”€โ”€ reproducibility-capsule/ # Experiment reproduction scripts +โ”œโ”€โ”€ services/ # Microservices +โ”‚ โ”œโ”€โ”€ api/ # REST API (FastAPI) +โ”‚ โ”œโ”€โ”€ calibrator/ # Topology calibration service +โ”‚ โ”œโ”€โ”€ dc-mock/ # Datacenter mock (data replay) +โ”‚ โ”œโ”€โ”€ grafana/ # Dashboard configuration +โ”‚ โ”œโ”€โ”€ kafka-init/ # Kafka topic initialization +โ”‚ โ””โ”€โ”€ simulator/ # OpenDC simulation engine +โ””โ”€โ”€ workload/ # Workload datasets + โ””โ”€โ”€ SURF/ # SURF datacenter workload ``` -See [Architecture Overview](docs/ARCHITECTURE.md) for detailed explanation. +--- -## Available Commands - -### Core Commands +## Commands | Command | Description | |---------|-------------| -| `make up` | Start with clean slate (deletes volumes) | -| `make up-debug` | Start in debug mode (local file output) | +| `make up` | Start all services | +| `make up config=path/to/config.yaml` | Start with custom configuration | | `make down` | Stop all services | -| `make logs` | View all logs | -| `make ps` | Show running containers | +| `make setup` | Install development dependencies | +| `make test` | Run tests | +| `make logs-` | View logs for a service | +| `make shell-` | Open shell in a container | -### Experiment Commands +Run `make help` for the complete list. -| Command | Description | -|---------|-------------| -| `make experiment name=X` | Run experiment X | -| `make experiment-debug name=X` | Run experiment X with debug output | -| `make experiment-down` | Stop experiment | +--- -### Development Commands +## Documentation -| Command | Description | -|---------|-------------| -| `make setup` | Setup virtual environment | -| `make test` | Run all tests | -| `make shell-dashboard` | Open shell in dashboard container | -| `make kafka-topics` | List Kafka topics | +| Document | Description | +|----------|-------------| +| [Getting Started](docs/GETTING_STARTED.md) | Installation, configuration, and running experiments | +| [Concepts](docs/CONCEPTS.md) | Core concepts, data models, and system behavior | +| [Configuration](docs/CONFIGURATION.md) | Configuration file reference | -### Monitoring Commands +### Service Documentation -| Command | Description | +| Service | Description | |---------|-------------| -| `make logs-dc-mock` | View dc-mock logs | -| `make logs-sim-worker` | View sim-worker logs | -| `make logs-dashboard` | View dashboard logs | - -Run `make help` to see all available commands. - -## Configuration - -### Basic Configuration - -**File**: `config/default.yaml` - -```yaml -workload: "SURF" # Data directory name - -simulation: - speed_factor: 300 # 300x real-time - window_size_minutes: 5 # 5-minute windows - heartbeat_frequency_minutes: 1 - experiment_mode: false - -kafka: - bootstrap_servers: "kafka:29092" -``` - -### Experiment Configuration - -**File**: `config/experiments/my_experiment.yaml` - -```yaml -workload: "SURF" - -simulation: - speed_factor: 300 - window_size_minutes: 15 # Longer windows for experiments - experiment_mode: true # Enable experiment mode -``` - -## System Components - -### Services - -- **dc-mock**: Replays historical workload/power data to Kafka -- **sim-worker**: Consumes streams, invokes OpenDC simulator -- **dashboard**: Web dashboard with REST API for system control and visualization - -### Infrastructure - -- **Kafka**: Message broker (KRaft mode, no Zookeeper) -- **PostgreSQL**: Database for persistent storage -- **OpenDC**: Java-based datacenter simulator (bundled) +| [dc-mock](services/dc-mock/README.md) | Replays historical workload and power data | +| [simulator](services/simulator/README.md) | Runs OpenDC simulations | +| [calibrator](services/calibrator/README.md) | Calibrates topology parameters | +| [api](services/api/README.md) | REST API for data queries | -### Shared Libraries +### Research -- **opendt-common**: Pydantic models, configuration, Kafka utilities +| Document | Description | +|----------|-------------| +| [Reproducibility Capsule](reproducibility-capsule/README.md) | Reproduce paper experiments | diff --git a/config/default.yaml b/config/default.yaml index 51170b8..1b4ea52 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -11,6 +11,7 @@ services: simulator: simulation_frequency_minutes: 5 + background_load_nodes: 0 kafka: topics: diff --git a/config/experiments/experiment_1.yaml b/config/experiments/experiment_1.yaml index 10d47c5..fb0c8fc 100644 --- a/config/experiments/experiment_1.yaml +++ b/config/experiments/experiment_1.yaml @@ -10,7 +10,8 @@ services: heartbeat_frequency_minutes: 1 simulator: - simulation_frequency_minutes: 15 + simulation_frequency_minutes: 60 + background_load_nodes: 0 kafka: topics: diff --git a/config/experiments/experiment_2.yaml b/config/experiments/experiment_2.yaml index 82ca375..d86878d 100644 --- a/config/experiments/experiment_2.yaml +++ b/config/experiments/experiment_2.yaml @@ -11,6 +11,7 @@ services: simulator: simulation_frequency_minutes: 60 + background_load_nodes: 0 calibrator: calibrated_property: "cpuPowerModel.calibrationFactor" diff --git a/docker-compose.yml b/docker-compose.yml index b68f4d9..8f7e527 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,26 +5,6 @@ services: # INFRASTRUCTURE SERVICES # ============================================================================ - postgres: - image: postgres:15-alpine - container_name: ${COMPOSE_PROJECT_NAME:-opendt}-postgres - environment: - POSTGRES_DB: opendt - POSTGRES_USER: opendt - POSTGRES_PASSWORD: opendt_dev_password - PGDATA: /var/lib/postgresql/data/pgdata - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U opendt"] - interval: 5s - timeout: 5s - retries: 5 - networks: - - opendt-network - kafka: image: confluentinc/cp-kafka:7.5.0 container_name: ${COMPOSE_PROJECT_NAME:-opendt}-kafka @@ -188,13 +168,12 @@ services: profiles: - calibration - dashboard: + api: build: context: . - dockerfile: services/dashboard/Dockerfile - container_name: ${COMPOSE_PROJECT_NAME:-opendt}-dashboard + dockerfile: services/api/Dockerfile + container_name: ${COMPOSE_PROJECT_NAME:-opendt}-api environment: - DATABASE_URL: postgresql://opendt:opendt_dev_password@postgres:5432/opendt KAFKA_BOOTSTRAP_SERVERS: kafka:29092 CONFIG_FILE: /app/config/simulation.yaml PYTHONUNBUFFERED: 1 @@ -202,10 +181,10 @@ services: DATA_DIR: /app/data WORKLOAD_DIR: /app/workload ports: - - "8000:8000" + - "3001:8000" volumes: # Mount source code for hot reload - - ./services/dashboard:/app/services/dashboard + - ./services/api:/app/services/api - ./libs/common:/app/libs/common # Mount configuration file - ${CONFIG_PATH:-./config/default.yaml}:/app/config/simulation.yaml:ro @@ -214,16 +193,11 @@ services: # Mount specific workload directory - ${WORKLOAD_PATH:-./workload/SURF}:/app/workload:ro depends_on: - postgres: - condition: service_healthy kafka: condition: service_healthy networks: - opendt-network - command: uvicorn dashboard.main:app --host 0.0.0.0 --port 8000 --reload - - - + command: uvicorn api.main:app --host 0.0.0.0 --port 8000 --reload grafana: image: grafana/grafana:latest @@ -231,19 +205,20 @@ services: restart: always environment: - GF_INSTALL_PLUGINS=yesoreyeram-infinity-datasource - - GF_SECURITY_ADMIN_PASSWORD=admin + # Disable authentication - anonymous access with Admin role + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + - GF_AUTH_DISABLE_LOGIN_FORM=true + # Set OpenDT dashboard as home page + - GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/etc/grafana/provisioning/dashboards/power_dashboard.json ports: - "3000:3000" volumes: - grafana-storage:/var/lib/grafana - - ./grafana/provisioning:/etc/grafana/provisioning + - ./services/grafana/provisioning:/etc/grafana/provisioning networks: - opendt-network - - - - # ============================================================================== # NETWORKS # ============================================================================== @@ -257,9 +232,7 @@ networks: # ============================================================================== volumes: - postgres_data: - name: opendt-postgres-data kafka_data: name: opendt-kafka-data grafana-storage: - name: opendt-grafana-storage \ No newline at end of file + name: opendt-grafana-storage diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index f1412f2..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,244 +0,0 @@ -# OpenDT Architecture - -Welcome to the OpenDT documentation! This document provides a comprehensive overview of the system's architecture, design principles, and core concepts. - -## Table of Contents - -- [System Overview](#system-overview) -- [Architecture Diagram](#architecture-diagram) -- [Services](#services) -- [Data Flow](#data-flow) -- [Kafka Topics](#kafka-topics) -- [Related Documentation](#related-documentation) - -## System Overview - -**OpenDT** (Open Digital Twin) is a distributed system for datacenter simulation and What-If analysis. It operates in "Shadow Mode" by replaying historical workload data through the OpenDC simulator to compare predicted vs. actual power consumption. - -### Key Objectives - -1. **Power Consumption Prediction**: Simulate datacenter power usage based on workload patterns -2. **What-If Analysis**: Answer questions like "What happens if we upgrade CPU architecture?" without touching live hardware -3. **Infrastructure Optimization**: Identify opportunities for energy efficiency improvements -4. **Real-time Comparison**: Continuously compare simulation predictions against actual telemetry - -### Core Capabilities - -- Event-time windowing with configurable window sizes (default: 5 minutes) -- Cumulative simulation for accurate long-running predictions -- Topology management (real vs. simulated configurations) -- Result caching to avoid redundant simulations -- Multiple operating modes (normal, debug, experiment) -- Dynamic plot generation for power consumption analysis - -## Architecture Diagram - -``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ OpenDT System โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ dc-mock โ”‚โ”€โ”€โ”€โ”€>โ”‚ Kafka Bus โ”‚ -โ”‚ (Producer) โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ Topics: โ”‚ โ”‚ -โ”‚ Reads: โ”‚ โ”‚ โ”‚ โ€ข dc.workload (tasks) โ”‚ โ”‚ -โ”‚ - tasks โ”‚ โ”‚ โ”‚ โ€ข dc.power (telemetry) โ”‚ โ”‚ -โ”‚ - fragments โ”‚ โ”‚ โ”‚ โ€ข dc.topology (real) โ”‚ โ”‚ -โ”‚ - power โ”‚ โ”‚ โ”‚ โ€ข sim.topology (simulated) โ”‚ โ”‚ -โ”‚ - topology โ”‚ โ”‚ โ”‚ โ€ข sim.results (predictions) โ”‚ โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ€ข sys.config (runtime cfg) โ”‚ โ”‚ - โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ โ”‚ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ sim-worker โ”‚ โ”‚ dashboard โ”‚ - โ”‚ (Consumer) โ”‚ โ”‚ (FastAPI) โ”‚ - โ”‚ โ”‚ โ”‚ โ”‚ - โ”‚ โ€ข Windows โ”‚ โ”‚ โ€ข Web UI โ”‚ - โ”‚ โ€ข OpenDC โ”‚ โ”‚ โ€ข REST API โ”‚ - โ”‚ โ€ข Caching โ”‚โ—€โ”€โ”€โ”€โ”€โ”‚ โ€ข Topology Mgmt โ”‚ - โ”‚ โ€ข Experiments โ”‚ โ”‚ โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ โ”‚ - โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ โ”‚ PostgreSQL โ”‚ - โ”‚ โ”‚ (TimescaleDB) โ”‚ - โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ Experiment โ”‚ - โ”‚ Output: โ”‚ - โ”‚ โ€ข Parquet โ”‚ - โ”‚ โ€ข Plots โ”‚ - โ”‚ โ€ข Archives โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` - -## Services - -OpenDT consists of 5 microservices orchestrated via Docker Compose: - -### 1. dc-mock (Datacenter Mock) - -**Purpose**: Simulates a real datacenter by replaying historical data - -**Location**: [`../services/dc-mock/`](../services/dc-mock/README.md) - -**Key Features**: -- Reads Parquet files (`tasks`, `fragments`, `consumption`) from `data//` -- Publishes to Kafka with configurable speed factor (e.g., 300x real-time) -- Three independent producers: Workload, Power, Topology -- Heartbeat mechanism for window synchronization - -**Produces To**: -- `dc.workload` - Task submissions + periodic heartbeats -- `dc.power` - Power consumption telemetry -- `dc.topology` - Real datacenter topology snapshots - ---- - -### 2. sim-worker (Simulation Engine) - -**Purpose**: Core simulation worker that bridges Kafka and OpenDC simulator - -**Location**: [`../services/sim-worker/`](../services/sim-worker/README.md) - -**Key Features**: -- Event-time windowing with heartbeat-driven closing -- Cumulative simulation (re-simulates all tasks from beginning) -- Result caching based on topology hash + task count -- Multiple operating modes (normal, debug, experiment) -- Integration with OpenDC binary (Java-based simulator) - -**Consumes From**: -- `dc.workload` - Tasks and heartbeats -- `dc.topology` - Real topology snapshots -- `sim.topology` - Simulated topology updates -- `dc.power` - Actual power (experiment mode) - -**Produces To**: -- `sim.results` - Simulation predictions (normal mode) -- Local files - Results and archives (debug/experiment mode) - ---- - -### 3. dashboard (Web Dashboard) - -**Purpose**: Web dashboard and REST API for system control and visualization - -**Location**: [`../services/dashboard/`](../services/dashboard/README.md) - -**Key Features**: -- Web UI for real-time visualization -- FastAPI with automatic OpenAPI documentation -- Topology management endpoint (`PUT /api/topology`) -- Health check and status endpoints -- Kafka producer for configuration updates -- Static file serving for dashboard assets - -**Routes**: -- `GET /` - Web dashboard UI -- `GET /health` - Health check (Kafka + config status) -- `GET /docs` - Interactive Swagger UI -- `PUT /api/topology` - Update simulated datacenter topology - ---- - -### 4. kafka-init (Infrastructure Initialization) - -**Purpose**: Creates Kafka topics with proper retention and compaction policies - -**Location**: [`../services/kafka-init/`](../services/kafka-init/README.md) - -**Key Features**: -- Reads topic configuration from YAML -- Creates topics on Kafka startup -- Applies retention policies and compaction settings -- Fail-fast on errors - ---- - -## Data Flow - -### 1. Data Ingestion - -``` -data/SURF/ -โ”œโ”€โ”€ tasks.parquet โ”€โ” -โ”œโ”€โ”€ fragments.parquet โ”€โ”คโ”€โ”€> dc-mock โ”€โ”€> dc.workload (Kafka) -โ”œโ”€โ”€ consumption.parquetโ”€โ”คโ”€โ”€> dc-mock โ”€โ”€> dc.power (Kafka) -โ””โ”€โ”€ topology.json โ”€โ”˜โ”€โ”€> dc-mock โ”€โ”€> dc.topology (Kafka) -``` - -### 2. Simulation Pipeline - -``` -dc.workload โ”€โ”€โ” - โ”œโ”€โ”€> sim-worker โ”€โ”€> OpenDC โ”€โ”€> results -dc.topology โ”€โ”€โ”ค (binary) -sim.topology โ”€โ”˜ -``` - -### 3. Window Processing - -1. **Task Arrival**: Tasks published to `dc.workload` with submission timestamps -2. **Window Assignment**: Task assigned to window based on rounded submission time -3. **Heartbeat Signal**: Periodic heartbeat messages indicate time progression -4. **Window Closing**: When heartbeat timestamp โ‰ฅ window end, close window -5. **Simulation**: Invoke OpenDC with cumulative tasks + simulated topology -6. **Caching**: Check if topology + task count match previous simulation -7. **Output**: Publish results or write to files based on operating mode - -### 4. Topology Management - -``` -User/Dashboard โ”€โ”€> PUT /api/topology โ”€โ”€> sim.topology (Kafka) โ”€โ”€> sim-worker - โ”‚ - โ”œโ”€โ”€> Update simulated topology - โ”œโ”€โ”€> Clear result cache - โ””โ”€โ”€> Use for future simulations -``` - -## Kafka Topics - -### Topic Overview - -| Topic | Type | Purpose | Retention | Key | -|-------|------|---------|-----------|-----| -| `dc.workload` | Stream | Task submissions + heartbeats | 24 hours | null | -| `dc.power` | Stream | Actual power telemetry | 1 hour | null | -| `dc.topology` | Compacted | Real datacenter topology | 1h lag | `datacenter` | -| `sim.topology` | Compacted | Simulated topology (What-If) | 0ms lag | `datacenter` | -| `sys.config` | Compacted | Runtime configuration | infinite | setting key | -| `sim.results` | Stream | Simulation predictions | 7 days | null | - -### Compaction Strategy - -**Compacted topics** (`dc.topology`, `sim.topology`, `sys.config`) keep only the latest value per key: -- Ensures consumers always get current state -- Enables efficient state recovery -- Reduces storage for infrequently changing data - -**Stream topics** (`dc.workload`, `dc.power`, `sim.results`) retain all messages up to retention period: -- Preserves full event history -- Enables time-travel and replay -- Supports multiple consumers at different offsets - -## Related Documentation - -### Service Documentation -- [dc-mock README](../services/dc-mock/README.md) - Datacenter mock producer -- [sim-worker README](../services/sim-worker/README.md) - Simulation engine -- [dashboard README](../services/dashboard/README.md) - Web dashboard and API -- [kafka-init README](../services/kafka-init/README.md) - Kafka initialization - -### Concept Documentation -- [Data Models](./DATA_MODELS.md) - Pydantic models and data structures - -### Development Resources -- [Root README](../README.md) - Quick start and setup -- [Makefile Commands](../Makefile) - Available `make` commands -- [Common Library](../libs/common/opendt_common/) - Shared Pydantic models and utilities diff --git a/docs/CONCEPTS.md b/docs/CONCEPTS.md new file mode 100644 index 0000000..08d9610 --- /dev/null +++ b/docs/CONCEPTS.md @@ -0,0 +1,197 @@ +# Concepts + +This document explains the core concepts, data models, and system behavior of OpenDT. + +## Overview + +OpenDT in its current state operates in **Shadow Mode**: it connects to a datacenter (real or mocked) and replays historical workload data through the OpenDC simulator. The system continuously compares predicted power consumption against actual measurements. + +**Key capabilities:** +- Power consumption prediction based on workload patterns +- What-If analysis (e.g., "What if we upgrade CPU architecture?") +- Real-time topology calibration +- Carbon emission estimation + +## Data Flow + +``` +Workload Data โ†’ dc-mock โ†’ Kafka โ†’ simulator โ†’ OpenDC โ†’ Results +---- +Results โ†’ api โ†’ Grafana +``` + +1. **dc-mock** reads historical workload and power data from Parquet files +2. Messages are published to Kafka topics +3. **simulator** consumes workload messages, aggregates them into time windows, and invokes OpenDC +4. **api** queries results and serves them to Grafana + +## Workload Data + +### Tasks + +A **Task** represents a job submitted to the datacenter. Each task requests compute resources for a specific duration. + +| Field | Type | Description | +|-------|------|-------------| +| id | int | Unique identifier | +| submission_time | datetime | When the task was submitted | +| duration | int | Total duration in milliseconds | +| cpu_count | int | Number of CPU cores requested | +| cpu_capacity | float | CPU speed in MHz | +| mem_capacity | int | Memory capacity in MB | +| fragments | list | Execution profile segments | + +**Physical interpretation:** A task represents a request for compute cycles: + +``` +Total Cycles = cpu_count ร— cpu_capacity ร— duration +``` + +### Fragments + +A **Fragment** describes resource usage during a segment of task execution. Tasks can have varying resource usage over time (e.g., high CPU at start, low CPU during I/O). + +| Field | Type | Description | +|-------|------|-------------| +| id | int | Fragment identifier | +| task_id | int | Parent task ID | +| duration | int | Segment duration in milliseconds | +| cpu_count | int | CPUs used in this segment | +| cpu_usage | float | CPU utilization value | + +### Consumption + +A **Consumption** record represents actual power telemetry from the datacenter. + +| Field | Type | Description | +|-------|------|-------------| +| timestamp | datetime | Measurement time | +| power_draw | float | Instantaneous power in Watts | +| energy_usage | float | Accumulated energy in Joules | + +## Topology + +The **Topology** defines the datacenter hardware that the simulator uses to calculate power. It is hierarchical: Clusters contain Hosts, which have CPUs, Memory, and a Power Model. + +### Structure + +``` +Topology +โ””โ”€โ”€ Cluster (e.g., "C01") + โ””โ”€โ”€ Host + โ”œโ”€โ”€ count: 277 (number of identical hosts) + โ”œโ”€โ”€ CPU + โ”‚ โ”œโ”€โ”€ coreCount: 16 + โ”‚ โ””โ”€โ”€ coreSpeed: 2100 MHz + โ”œโ”€โ”€ Memory + โ”‚ โ””โ”€โ”€ memorySize: 128 GB + โ””โ”€โ”€ CPUPowerModel + โ”œโ”€โ”€ modelType: "mse" + โ”œโ”€โ”€ idlePower: 25 W + โ”œโ”€โ”€ maxPower: 174 W + โ””โ”€โ”€ calibrationFactor: 10.0 +``` + +### Power Models + +The **CPUPowerModel** defines how CPU utilization translates to power consumption. + +| Model Type | Description | +|------------|-------------| +| mse | Mean Squared Error based model (default) | +| asymptotic | Non-linear curve with asymptotic behavior | +| linear | Linear interpolation between idle and max power | + +Key parameters: +- **idlePower**: Power draw at 0% utilization (Watts) +- **maxPower**: Power draw at 100% utilization (Watts) +- **calibrationFactor**: Scaling factor for the mse model + +## Time Windows + +The simulator aggregates tasks into **time windows** for batch simulation. + +### Window Behavior + +1. Tasks are assigned to windows based on their submission timestamp +2. Windows close when a heartbeat message indicates time has progressed past the window end +3. When a window closes, all accumulated tasks are simulated + +### Cumulative Simulation + +OpenDT uses cumulative simulation: each window simulates all tasks from the beginning of the workload, not just tasks in that window. This ensures accurate long-running predictions. + +### Heartbeats + +**Heartbeat messages** are synthetic timestamps published by dc-mock to signal time progression. They enable deterministic window closing even when no tasks arrive. + +## Calibration + +When enabled, the **calibrator** service optimizes topology parameters by comparing simulation output against actual power measurements. + +### Process + +1. Calibrator runs parallel simulations with different parameter values +2. Each simulation result is compared against actual power (MAPE calculation) +3. The parameter value with lowest error is selected +4. Updated topology is published to Kafka +5. Simulator uses the calibrated topology for future windows + +## Kafka Topics + +OpenDT uses Kafka for inter-service communication. + +| Topic | Purpose | +|-------|---------| +| dc.workload | Task submissions and heartbeats | +| dc.power | Actual power consumption telemetry | +| dc.topology | Real datacenter topology | +| sim.topology | Simulated/calibrated topology | +| sim.results | Simulation predictions | + +## Output Files + +### Aggregated Results + +`simulator/agg_results.parquet` contains the combined simulation output: + +| Column | Description | +|--------|-------------| +| timestamp | Simulation timestamp | +| power_draw | Predicted power in Watts | +| carbon_intensity | Grid carbon intensity (gCO2/kWh) | + +### OpenDC Archives + +Each simulation run is archived in `simulator/opendc/run_/`: + +``` +run_1/ +โ”œโ”€โ”€ input/ +โ”‚ โ”œโ”€โ”€ experiment.json # OpenDC experiment config +โ”‚ โ”œโ”€โ”€ topology.json # Topology used +โ”‚ โ”œโ”€โ”€ tasks.parquet # Tasks simulated +โ”‚ โ””โ”€โ”€ fragments.parquet # Task fragments +โ”œโ”€โ”€ output/ +โ”‚ โ”œโ”€โ”€ powerSource.parquet # Power timeseries +โ”‚ โ”œโ”€โ”€ host.parquet # Host-level metrics +โ”‚ โ””โ”€โ”€ service.parquet # Service-level metrics +โ””โ”€โ”€ metadata.json # Run metadata +``` + +## Pydantic Models + +All data models are defined using Pydantic v2 in `libs/common/odt_common/models/`: + +| Model | File | Description | +|-------|------|-------------| +| Task | task.py | Workload task | +| Fragment | fragment.py | Task execution segment | +| Consumption | consumption.py | Power measurement | +| Topology | topology.py | Datacenter topology | +| WorkloadMessage | workload_message.py | Kafka message wrapper | + +Models provide: +- Runtime type validation +- JSON serialization/deserialization +- Automatic API documentation diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..210678d --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,90 @@ +# Configuration + +OpenDT uses a single YAML configuration file that controls all services. Configuration files are stored in the `config/` directory. + +## File Location + +- **Default:** `config/default.yaml` +- **Experiments:** `config/experiments/experiment_1.yaml`, `config/experiments/experiment_2.yaml` + +Run with a specific config: + +``` +make up config=config/experiments/experiment_1.yaml +``` + +## Configuration Structure + +```yaml +global: + speed_factor: 1 + calibration_enabled: false + +services: + dc-mock: + # Service-specific settings + simulator: + # Service-specific settings + +kafka: + topics: + # Topic definitions +``` + +## Global Settings + +The `global:` section contains settings that affect the entire system. + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| speed_factor | float | 1 | Simulation speed multiplier | +| calibration_enabled | bool | false | Enable the calibrator service | + +### speed_factor + +Controls how fast the simulation runs relative to real time: + +| Value | Behavior | +|-------|----------| +| 1 | Real-time (1 second simulation = 1 second wall clock) | +| 300 | 300x speed (1 hour simulation = 12 seconds wall clock) | +| -1 | Maximum speed (no delays between messages) | + +With the SURF workload (~7 days of data) and `speed_factor: 300`, the simulation completes in approximately 1 hour. + +### calibration_enabled + +When `true`, the calibrator service starts and actively tunes topology parameters during the simulation run. Requires the `calibration` Docker Compose profile. + +## Service Settings + +The `services:` section contains configuration for individual services. See each service's README for detailed configuration options: + +- [dc-mock](../services/dc-mock/README.md#configuration) โ€” Workload replay settings +- [simulator](../services/simulator/README.md#configuration) โ€” Simulation frequency +- [calibrator](../services/calibrator/README.md#configuration) โ€” Calibration search space and parallelism + +## Experiment Configurations + +Pre-configured experiments are in `config/experiments/`: + +| File | Description | +|------|-------------| +| experiment_1.yaml | Power prediction without calibration (speed_factor: 300) | +| experiment_2.yaml | Power prediction with calibration enabled (speed_factor: 300) | + +## Custom Configurations + +1. Copy an existing configuration file +2. Modify settings as needed +3. Run with: `make up config=path/to/your/config.yaml` + +## Environment Variables + +Some settings can be overridden via environment variables: + +| Variable | Description | +|----------|-------------| +| CONFIG_FILE | Path to configuration file | +| RUN_ID | Current run identifier | +| KAFKA_BOOTSTRAP_SERVERS | Kafka broker address | diff --git a/docs/DATA_MODELS.md b/docs/DATA_MODELS.md deleted file mode 100644 index 46757c2..0000000 --- a/docs/DATA_MODELS.md +++ /dev/null @@ -1,501 +0,0 @@ -# Data Models - -This document describes all data models used in OpenDT, their structure, validation rules, and usage patterns. - -## Table of Contents - -- [Overview](#overview) -- [Workload Models](#workload-models) -- [Topology Models](#topology-models) -- [Telemetry Models](#telemetry-models) -- [Message Wrappers](#message-wrappers) -- [Simulation Results](#simulation-results) -- [Data Physics](#data-physics) - -## Overview - -All data models in OpenDT are defined using **Pydantic v2** for: -- Runtime type validation -- JSON serialization/deserialization -- Automatic API documentation -- Data integrity guarantees - -**Location**: [`../libs/common/opendt_common/models/`](../libs/common/opendt_common/models/) - -## Workload Models - -### Task - -Represents a workload submission to the datacenter. - -**File**: [`task.py`](../libs/common/opendt_common/models/task.py) - -```python -class Task(BaseModel): - """A workload task submitted to the datacenter.""" - - id: int # Unique task identifier - submission_time: datetime # When task was submitted (ISO 8601) - duration: int # Total duration in milliseconds - cpu_count: int # Number of CPU cores requested - cpu_capacity: float # CPU speed in MHz - mem_capacity: int # Memory capacity in MB - fragments: list[Fragment] # Execution profile fragments -``` - -**Physical Interpretation**: -A task represents a request for compute cycles: -``` -Total Cycles = cpu_count ร— cpu_capacity ร— duration ร— 1000 -``` - -**Example**: -```json -{ - "id": 2132895, - "submission_time": "2022-10-07T00:39:21", - "duration": 12000, - "cpu_count": 16, - "cpu_capacity": 33600.0, - "mem_capacity": 100000, - "fragments": [...] -} -``` - ---- - -### Fragment - -Represents a fine-grained execution profile segment of a task. - -**File**: [`fragment.py`](../libs/common/opendt_common/models/fragment.py) - -```python -class Fragment(BaseModel): - """A time segment of task execution with specific resource usage.""" - - id: int # Fragment identifier - task_id: int # Parent task ID - duration: int # Fragment duration in milliseconds - cpu_count: int # Number of CPUs used in this fragment - cpu_usage: float # CPU utilization for this fragment -``` - -**Purpose**: Fragments describe non-uniform resource usage over time. For example: -- First 1000ms: 100% CPU utilization (cpu_usage = 16.0 for 16 cores) -- Next 2000ms: 50% CPU utilization (cpu_usage = 8.0 for 16 cores) - -**Example**: -```json -{ - "id": 1, - "task_id": 2132895, - "duration": 5000, - "cpu_count": 16, - "cpu_usage": 147.0 -} -``` - ---- - -### WorkloadMessage - -Wrapper for messages on `dc.workload` topic, distinguishing tasks from heartbeats. - -**File**: [`workload_message.py`](../libs/common/opendt_common/models/workload_message.py) - -```python -class WorkloadMessage(BaseModel): - """Wrapper for messages on dc.workload topic.""" - - message_type: Literal["task", "heartbeat"] # Message type discriminator - timestamp: datetime # Simulation timestamp - task: Task | None = None # Task data (only if type="task") -``` - -**Usage**: - -Task message: -```json -{ - "message_type": "task", - "timestamp": "2022-10-07T00:39:21", - "task": { /* Task object */ } -} -``` - -Heartbeat message: -```json -{ - "message_type": "heartbeat", - "timestamp": "2022-10-07T00:45:00", - "task": null -} -``` - -**Purpose**: Heartbeats signal time progression to consumers, enabling deterministic window closing even when no tasks arrive. - -## Topology Models - -### Topology - -Root model representing datacenter infrastructure. - -**File**: [`topology.py`](../libs/common/opendt_common/models/topology.py) - -```python -class Topology(BaseModel): - """Datacenter topology definition.""" - - clusters: list[Cluster] # List of clusters (min 1 required) - - # Helper methods - def total_host_count() -> int - def total_core_count() -> int - def total_memory_bytes() -> int -``` - -**Example**: -```json -{ - "clusters": [ - { - "name": "A01", - "hosts": [/* Host objects */] - } - ] -} -``` - ---- - -### Cluster - -Represents a logical group of hosts. - -```python -class Cluster(BaseModel): - """Cluster of hosts in a datacenter.""" - - name: str # Cluster identifier - hosts: list[Host] # List of host configurations (min 1 required) -``` - ---- - -### Host - -Represents a physical server configuration (possibly replicated). - -```python -class Host(BaseModel): - """Host (physical server) in a datacenter cluster.""" - - name: str # Host identifier/name - count: int # Number of identical hosts - cpu: CPU # CPU specification - memory: Memory # Memory specification - cpuPowerModel: CPUPowerModel # Power consumption model -``` - -**Example**: -```json -{ - "name": "A01-Host", - "count": 277, - "cpu": { "coreCount": 16, "coreSpeed": 2100 }, - "memory": { "memorySize": 128000000 }, - "cpuPowerModel": { /* Power model */ } -} -``` - ---- - -### CPU - -CPU hardware specification. - -```python -class CPU(BaseModel): - """CPU specification for a host.""" - - coreCount: int # Number of CPU cores (> 0) - coreSpeed: float # CPU speed in MHz (> 0) -``` - ---- - -### Memory - -Memory hardware specification. - -```python -class Memory(BaseModel): - """Memory specification for a host.""" - - memorySize: int # Memory size in bytes (> 0) -``` - ---- - -### CPUPowerModel - -Defines how CPU utilization translates to power consumption. - -```python -class CPUPowerModel(BaseModel): - """CPU power consumption model.""" - - modelType: Literal["asymptotic", "linear", "square", "cubic", "sqrt"] - power: float # Nominal power consumption in Watts (> 0) - idlePower: float # Power at 0% utilization in Watts (โ‰ฅ 0) - maxPower: float # Power at 100% utilization in Watts (> 0) - asymUtil: float = 0.5 # Asymptotic utilization coefficient (0-1) - dvfs: bool = False # Dynamic Voltage/Frequency Scaling enabled -``` - -**Power Model Types**: -- **asymptotic**: Realistic non-linear curve (recommended) -- **linear**: Simple linear interpolation between idle and max -- **square**: Quadratic relationship -- **cubic**: Cubic relationship -- **sqrt**: Square root relationship - -**Example**: -```json -{ - "modelType": "asymptotic", - "power": 400.0, - "idlePower": 32.0, - "maxPower": 180.0, - "asymUtil": 0.3, - "dvfs": false -} -``` - ---- - -### TopologySnapshot - -Timestamped wrapper for topology on `dc.topology` topic. - -```python -class TopologySnapshot(BaseModel): - """Timestamped topology snapshot for Kafka messages.""" - - timestamp: datetime # When snapshot was captured (ISO 8601) - topology: Topology # The datacenter topology -``` - -**Purpose**: Adds temporal context to topology updates, enabling time-travel and audit trails. - -**Example**: -```json -{ - "timestamp": "2022-10-07T09:14:30", - "topology": { /* Topology object */ } -} -``` - -## Telemetry Models - -### Consumption - -Power consumption telemetry from datacenter. - -**File**: [`consumption.py`](../libs/common/opendt_common/models/consumption.py) - -```python -class Consumption(BaseModel): - """Power consumption measurement from datacenter.""" - - power_draw: float # Instantaneous power in Watts - energy_usage: float # Accumulated energy in Joules - timestamp: datetime # Measurement timestamp (ISO 8601) -``` - -**Example**: -```json -{ - "power_draw": 19180.0, - "energy_usage": 575400.0, - "timestamp": "2022-10-08T06:35:30" -} -``` - -**Units**: -- `power_draw`: Watts (W) -- `energy_usage`: Joules (J) - accumulated since last snapshot -- To convert Joules to kWh: `kWh = joules / 3,600,000` - -## Simulation Results - -### SimulationResults - -Output from OpenDC simulator. - -**File**: [`sim_worker/runner/models.py`](../services/sim-worker/sim_worker/runner/models.py) - -```python -class SimulationResults(BaseModel): - """Results from OpenDC simulation.""" - - status: str # "success" or "error" - error: str | None = None # Error message if failed - - # Summary Statistics - energy_kwh: float = 0.0 # Total energy in kilowatt-hours - cpu_utilization: float = 0.0 # Average CPU utilization (0.0-1.0) - max_power_draw: float = 0.0 # Maximum power in Watts - runtime_hours: float = 0.0 # Simulated runtime duration - - # Timeseries Data - power_draw_series: list[TimeseriesData] = [] # Power over time - cpu_utilization_series: list[TimeseriesData] = [] # CPU util over time - - # Metadata - temp_dir: str | None = None # Temporary directory path - opendc_output_dir: str | None = None # OpenDC output directory -``` - ---- - -### TimeseriesData - -Timeseries data point. - -```python -class TimeseriesData(BaseModel): - """Single timeseries data point.""" - - timestamp: int # Milliseconds offset from simulation start - value: float # Measured value -``` - -**Example**: -```json -{ - "timestamp": 150000, - "value": 18750.5 -} -``` - -## Data Physics - -### Task Execution - -A task's resource requirements define the total compute cycles needed: - -```python -total_cycles = cpu_count ร— cpu_capacity ร— duration ร— 1000 -``` - -**Example**: -- 16 cores ร— 3360 MHz ร— 12 seconds = 644,352,000 cycles - -### Fragment Profiling - -Fragments break down task execution into segments with varying resource usage: - -1. **Bursty Task**: - - Fragment 1 (0-1s): 100% CPU - - Fragment 2 (1-10s): 20% CPU - - Fragment 3 (10-12s): 80% CPU - -2. **Steady Task**: - - Single fragment (0-12s): 75% CPU - -This allows accurate power modeling for real-world workload patterns. - -### Energy Integration - -Total energy consumed: -``` -Energy (Joules) = โˆซ Power(t) dt -Energy (kWh) = Joules / 3,600,000 -``` - -## Validation Rules - -### Field Constraints - -Pydantic enforces validation at runtime: - -- **Positive integers**: `gt=0` (cpu_count, duration, count) -- **Non-negative floats**: `ge=0` (idlePower) -- **Positive floats**: `gt=0` (cpu_capacity, maxPower) -- **Ranges**: `ge=0, le=1` (asymUtil, cpu_utilization) -- **Min length**: `min_length=1` (clusters, hosts, fragments) -- **Datetime**: ISO 8601 format required - -### Example Validation - -```python -from opendt_common.models import Task - -# Valid task -task = Task( - id=1, - submission_time="2022-10-07T00:39:21", - duration=1000, - cpu_count=8, - cpu_capacity=2400.0, - mem_capacity=64000, - fragments=[] -) - -# Invalid task (negative duration) -try: - Task( - id=2, - duration=-100, # โŒ Will raise ValidationError - ... - ) -except ValidationError as e: - print(e) -``` - -## Usage Patterns - -### Serialization - -```python -from opendt_common.models import Task - -# To JSON -task_json = task.model_dump(mode="json") -task_str = task.model_dump_json() - -# From JSON -task = Task(**json_data) -task = Task.model_validate_json(json_string) -``` - -### Kafka Integration - -```python -from opendt_common.utils.kafka import send_message -from opendt_common.models import TopologySnapshot - -snapshot = TopologySnapshot( - timestamp=datetime.now(), - topology=topology -) - -send_message( - producer=producer, - topic="dc.topology", - message=snapshot.model_dump(mode="json"), - key="datacenter" -) -``` - -## Related Documentation - -- [Architecture Overview](./ARCHITECTURE.md) - System design and data flow -- [Dashboard Documentation](../services/dashboard/README.md) - Web UI and REST API using these models -- [Simulation Worker](../services/sim-worker/README.md) - How models are used in simulation - ---- - -For model source code, see [`libs/common/opendt_common/models/`](../libs/common/opendt_common/models/). diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md new file mode 100644 index 0000000..d98488b --- /dev/null +++ b/docs/GETTING_STARTED.md @@ -0,0 +1,100 @@ +# Getting Started + +This guide covers installation and running OpenDT. + +## Prerequisites + +- **Docker & Docker Compose** - Container runtime +- **Make** - Build automation +- **Python 3.11+** - For local development +- **uv** - Python package manager ([install](https://astral.sh/uv)) + +## Installation + +Clone the repository and set up the development environment: + +``` +git clone https://github.com/atlarge-research/opendt.git +cd opendt +make setup +``` + +This creates a virtual environment and installs all dependencies. + +## Running OpenDT + +Start all services with the default configuration: + +``` +make up +``` + +This will: +1. Initialize a new run with a timestamped ID +2. Start Kafka, the simulator, dc-mock, and supporting services +3. Begin replaying workload data and generating power predictions + +Run with a specific configuration file: + +``` +make up config=config/experiments/experiment_1.yaml +``` + +Stop services: + +``` +make down +``` + +## Accessing the System + +| Service | URL | Description | +|---------|-----|-------------| +| Dashboard | http://localhost:3000 | Grafana visualization | +| API | http://localhost:3001 | REST API with OpenAPI docs | + +## Viewing Logs + +``` +make logs-simulator +make logs-dc-mock +make logs-calibrator +make logs-api +``` + +## Run Output + +Each run creates a timestamped directory under `data/`: + +``` +data/2024_01_15_10_30_00/ +โ”œโ”€โ”€ config.yaml # Copy of configuration used +โ”œโ”€โ”€ metadata.json # Run metadata +โ”œโ”€โ”€ .env # Environment variables +โ””โ”€โ”€ simulator/ + โ”œโ”€โ”€ agg_results.parquet # Aggregated simulation results + โ””โ”€โ”€ opendc/ # OpenDC simulation archives +``` + +## Development + +Activate the environment: + +``` +source .venv/bin/activate +``` + +Run tests: + +``` +make test +``` + +Open a container shell: + +``` +make shell-simulator +make shell-dc-mock +make shell-calibrator +make shell-api +``` diff --git a/grafana/provisioning/dashboards/power_dashboard.json b/grafana/provisioning/dashboards/power_dashboard.json deleted file mode 100644 index 920763c..0000000 --- a/grafana/provisioning/dashboards/power_dashboard.json +++ /dev/null @@ -1,178 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": 2, - "links": [], - "panels": [ - { - "datasource": { - "type": "yesoreyeram-infinity-datasource", - "uid": "df5tfgbpfeakgc" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "showValues": false, - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "actual_power" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "light-red", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "hideZeros": false, - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "12.3.0", - "targets": [ - { - "columns": [], - "datasource": { - "type": "yesoreyeram-infinity-datasource", - "uid": "df5tfgbpfeakgc" - }, - "filters": [], - "format": "table", - "global_query_id": "", - "parser": "backend", - "refId": "A", - "root_selector": "data", - "source": "url", - "type": "json", - "url": "http://dashboard:8000/api/power?interval_seconds=60", - "url_options": { - "data": "", - "method": "GET" - } - } - ], - "title": "New panel", - "transformations": [ - { - "id": "convertFieldType", - "options": { - "conversions": [ - { - "destinationType": "time", - "targetField": "timestamp" - } - ], - "fields": {} - } - } - ], - "type": "timeseries" - } - ], - "preload": false, - "refresh": "", - "schemaVersion": 42, - "tags": [], - "templating": { - "list": [] - }, - "time": { - "from": "2022-10-06T22:02:30.000Z", - "to": "2022-10-09T06:44:30.000Z" - }, - "timepicker": {}, - "timezone": "browser", - "title": "opendt_power", - "uid": "adtg2gc", - "version": 1 -} \ No newline at end of file diff --git a/libs/common/README.md b/libs/common/README.md new file mode 100644 index 0000000..3f19a22 --- /dev/null +++ b/libs/common/README.md @@ -0,0 +1,76 @@ +# odt_common + +Shared library containing Pydantic models, configuration, and utilities used across OpenDT services. + +## Installation + +``` +pip install -e libs/common +``` + +Or with test dependencies: + +``` +pip install -e "libs/common[test]" +``` + +## Contents + +### Models (`odt_common.models`) + +Pydantic v2 models for data validation and serialization. + +| Model | File | Description | +|-------|------|-------------| +| Task | task.py | Workload task submission | +| Fragment | fragment.py | Task execution segment | +| Consumption | consumption.py | Power consumption measurement | +| Topology | topology.py | Datacenter topology (clusters, hosts, CPUs) | +| WorkloadMessage | workload_message.py | Kafka message wrapper | + +### Configuration (`odt_common.config`) + +Configuration loading from YAML files. + +```python +from odt_common import load_config_from_env + +config = load_config_from_env() +print(config.workload) # "SURF" +print(config.kafka.bootstrap_servers) # "kafka:29092" +``` + +### Utilities (`odt_common.utils`) + +Kafka producer/consumer helpers. + +```python +from odt_common.utils import get_kafka_producer, get_kafka_consumer +from odt_common.utils.kafka import send_message +``` + +### OpenDC Runner (`odt_common.odc_runner`) + +OpenDC binary invocation and result parsing. + +```python +from odt_common.odc_runner import OpenDCRunner + +runner = OpenDCRunner() +results = runner.run_simulation(tasks, topology) +``` + +### Task Accumulator (`odt_common.task_accumulator`) + +Window-based task accumulation for simulation. + +### Result Cache (`odt_common.result_cache`) + +Caching layer to avoid redundant simulations. + +## Testing + +``` +cd libs/common +pytest tests/ +``` diff --git a/libs/common/odt_common/config.py b/libs/common/odt_common/config.py index cdf283f..fd913fc 100644 --- a/libs/common/odt_common/config.py +++ b/libs/common/odt_common/config.py @@ -66,6 +66,11 @@ class SimulatorConfig(BaseModel): simulation_frequency_minutes: int = Field( default=15, description="Simulation frequency in minutes (simulated time)", gt=0 ) + background_load_nodes: int = Field( + ..., + description="Number of nodes reserved for background load (not available for simulation)", + ge=0, + ) class CalibratorConfig(BaseModel): diff --git a/libs/common/tests/test_models.py b/libs/common/tests/test_models.py index 52eaee4..673372c 100644 --- a/libs/common/tests/test_models.py +++ b/libs/common/tests/test_models.py @@ -8,7 +8,7 @@ from odt_common import Consumption, Fragment, Task # Locate test data -DATA_DIR = Path(__file__).parent.parent.parent.parent / "data" / "SURF" +DATA_DIR = Path(__file__).parent.parent.parent.parent / "workload" / "SURF" TASKS_FILE = DATA_DIR / "tasks.parquet" FRAGMENTS_FILE = DATA_DIR / "fragments.parquet" CONSUMPTION_FILE = DATA_DIR / "consumption.parquet" @@ -40,19 +40,19 @@ def consumption_df(): class TestTaskModel: """Test Task Pydantic model.""" - + def test_parse_first_task(self, tasks_df): """Test parsing first task row.""" task_dict = tasks_df.iloc[0].to_dict() task = Task(**task_dict) - + assert isinstance(task.id, int) assert isinstance(task.submission_time, datetime) assert task.duration > 0 assert task.cpu_count >= 0 assert task.cpu_capacity >= 0 assert task.mem_capacity >= 0 - + def test_parse_all_tasks(self, tasks_df): """Test parsing all tasks.""" errors = [] @@ -61,35 +61,37 @@ def test_parse_all_tasks(self, tasks_df): Task(**row.to_dict()) except Exception as e: errors.append(f"Row {idx}: {e}") - + assert len(errors) == 0, f"Failed to parse {len(errors)} tasks:\n" + "\n".join(errors[:5]) - + def test_task_id_parsing(self, tasks_df): """Test ID parsing from string.""" task_dict = tasks_df.iloc[0].to_dict() - task_dict['id'] = f"task-{task_dict['id']}" + task_dict["id"] = f"task-{task_dict['id']}" task = Task(**task_dict) assert isinstance(task.id, int) - + def test_task_properties(self, tasks_df): """Test computed properties.""" task = Task(**tasks_df.iloc[0].to_dict()) - + assert task.duration_seconds == task.duration / 1000.0 assert task.total_cpu_mhz == task.cpu_count * task.cpu_capacity assert task.mem_capacity_gb == task.mem_capacity / 1024.0 - + def test_task_with_fragments(self, tasks_df, fragments_df): """Test task with nested fragments.""" - first_task_id = tasks_df.iloc[0]['id'] - fragments_by_task = fragments_df.groupby('id') - + first_task_id = tasks_df.iloc[0]["id"] + fragments_by_task = fragments_df.groupby("id") + task_dict = tasks_df.iloc[0].to_dict() - + if first_task_id in fragments_by_task.groups: task_fragments = fragments_by_task.get_group(first_task_id) - task_dict['fragments'] = [Fragment(**row.to_dict()) for _, row in task_fragments.iterrows()] - + task_dict["fragments"] = [ + Fragment(**row.to_dict()) for _, row in task_fragments.iterrows() + ] + task = Task(**task_dict) assert isinstance(task.fragments, list) assert task.fragment_count == len(task.fragments) @@ -97,17 +99,17 @@ def test_task_with_fragments(self, tasks_df, fragments_df): class TestFragmentModel: """Test Fragment Pydantic model.""" - + def test_parse_first_fragment(self, fragments_df): """Test parsing first fragment row.""" fragment_dict = fragments_df.iloc[0].to_dict() fragment = Fragment(**fragment_dict) - + assert isinstance(fragment.task_id, int) assert fragment.duration > 0 assert fragment.cpu_count >= 0 assert fragment.cpu_usage >= 0 - + def test_parse_all_fragments(self, fragments_df): """Test parsing all fragments.""" errors = [] @@ -116,36 +118,38 @@ def test_parse_all_fragments(self, fragments_df): Fragment(**row.to_dict()) except Exception as e: errors.append(f"Row {idx}: {e}") - - assert len(errors) == 0, f"Failed to parse {len(errors)} fragments:\n" + "\n".join(errors[:5]) - + + assert len(errors) == 0, f"Failed to parse {len(errors)} fragments:\n" + "\n".join( + errors[:5] + ) + def test_fragment_id_parsing(self, fragments_df): """Test ID parsing with alias.""" fragment_dict = fragments_df.iloc[0].to_dict() - fragment_dict['id'] = f"task-{fragment_dict['id']}" + fragment_dict["id"] = f"task-{fragment_dict['id']}" fragment = Fragment(**fragment_dict) assert isinstance(fragment.task_id, int) - + def test_fragment_properties(self, fragments_df): """Test computed properties.""" fragment = Fragment(**fragments_df.iloc[0].to_dict()) - + assert fragment.duration_seconds == fragment.duration / 1000.0 assert fragment.total_cpu_usage_mhz == fragment.cpu_count * fragment.cpu_usage class TestConsumptionModel: """Test Consumption Pydantic model.""" - + def test_parse_first_consumption(self, consumption_df): """Test parsing first consumption row.""" cons_dict = consumption_df.iloc[0].to_dict() consumption = Consumption(**cons_dict) - + assert consumption.power_draw >= 0 assert consumption.energy_usage >= 0 assert isinstance(consumption.timestamp, datetime) - + def test_parse_all_consumption(self, consumption_df): """Test parsing all consumption records.""" errors = [] @@ -154,42 +158,44 @@ def test_parse_all_consumption(self, consumption_df): Consumption(**row.to_dict()) except Exception as e: errors.append(f"Row {idx}: {e}") - + assert len(errors) == 0, f"Failed to parse {len(errors)} records:\n" + "\n".join(errors[:5]) - + def test_consumption_properties(self, consumption_df): """Test computed properties.""" consumption = Consumption(**consumption_df.iloc[0].to_dict()) - + assert consumption.power_draw_kw == consumption.power_draw / 1000.0 assert consumption.energy_usage_kwh == consumption.energy_usage / 3_600_000.0 class TestAggregation: """Test task-fragment aggregation logic.""" - + def test_fragments_match_tasks(self, tasks_df, fragments_df): """Test that fragment IDs match task IDs.""" - task_ids = set(tasks_df['id'].unique()) - fragment_ids = set(fragments_df['id'].unique()) - + task_ids = set(tasks_df["id"].unique()) + fragment_ids = set(fragments_df["id"].unique()) + assert fragment_ids.issubset(task_ids), "Found fragments with non-existent task IDs" - + def test_full_aggregation(self, tasks_df, fragments_df): """Test full aggregation process.""" - fragments_by_task = fragments_df.groupby('id') - + fragments_by_task = fragments_df.groupby("id") + tasks = [] for _, task_row in tasks_df.iterrows(): task_dict = task_row.to_dict() - task_id = task_dict['id'] - + task_id = task_dict["id"] + if task_id in fragments_by_task.groups: task_fragments = fragments_by_task.get_group(task_id) - task_dict['fragments'] = [Fragment(**row.to_dict()) for _, row in task_fragments.iterrows()] - + task_dict["fragments"] = [ + Fragment(**row.to_dict()) for _, row in task_fragments.iterrows() + ] + tasks.append(Task(**task_dict)) - + assert len(tasks) == len(tasks_df) total_fragments = sum(t.fragment_count for t in tasks) assert total_fragments == len(fragments_df) @@ -197,31 +203,31 @@ def test_full_aggregation(self, tasks_df, fragments_df): class TestSerializationDeserialization: """Test JSON serialization/deserialization.""" - + def test_task_json_roundtrip(self, tasks_df): """Test task JSON serialization.""" task = Task(**tasks_df.iloc[0].to_dict()) json_str = task.model_dump_json() task_dict = task.model_dump() - + assert isinstance(json_str, str) assert isinstance(task_dict, dict) - assert task_dict['id'] == task.id - + assert task_dict["id"] == task.id + def test_fragment_json_roundtrip(self, fragments_df): """Test fragment JSON serialization.""" fragment = Fragment(**fragments_df.iloc[0].to_dict()) json_str = fragment.model_dump_json() fragment_dict = fragment.model_dump() - + assert isinstance(json_str, str) assert isinstance(fragment_dict, dict) - + def test_consumption_json_roundtrip(self, consumption_df): """Test consumption JSON serialization.""" consumption = Consumption(**consumption_df.iloc[0].to_dict()) json_str = consumption.model_dump_json() cons_dict = consumption.model_dump() - + assert isinstance(json_str, str) assert isinstance(cons_dict, dict) diff --git a/libs/common/tests/test_topology.py b/libs/common/tests/test_topology.py index 0d7b3df..8f4c9eb 100644 --- a/libs/common/tests/test_topology.py +++ b/libs/common/tests/test_topology.py @@ -5,13 +5,13 @@ from pathlib import Path import pytest - from odt_common.models.topology import ( CPU, + AsymptoticCPUPowerModel, Cluster, - CPUPowerModel, Host, Memory, + MseCPUPowerModel, Topology, TopologySnapshot, ) @@ -68,7 +68,7 @@ def test_memory_model(): def test_cpu_power_model(): """Test CPUPowerModel validation.""" - power_model = CPUPowerModel( + power_model = AsymptoticCPUPowerModel( modelType="asymptotic", power=400.0, idlePower=32.0, @@ -91,7 +91,7 @@ def test_host_model(): count=277, cpu=CPU(coreCount=16, coreSpeed=2100.0), memory=Memory(memorySize=128000000), - cpuPowerModel=CPUPowerModel( + cpuPowerModel=AsymptoticCPUPowerModel( modelType="asymptotic", power=400.0, idlePower=32.0, @@ -116,7 +116,7 @@ def test_cluster_model(): count=277, cpu=CPU(coreCount=16, coreSpeed=2100.0), memory=Memory(memorySize=128000000), - cpuPowerModel=CPUPowerModel( + cpuPowerModel=AsymptoticCPUPowerModel( modelType="asymptotic", power=400.0, idlePower=32.0, @@ -182,7 +182,7 @@ def test_topology_model_dump(sample_topology_data): def test_topology_from_surf_file(): """Test loading actual SURF topology file if it exists.""" - surf_topology_path = Path(__file__).parent.parent.parent.parent / "data/SURF/topology.json" + surf_topology_path = Path(__file__).parent.parent.parent.parent / "workload/SURF/topology.json" if not surf_topology_path.exists(): pytest.skip("SURF topology file not found") @@ -243,11 +243,12 @@ def test_topology_snapshot_with_microseconds(): count=1, cpu=CPU(coreCount=8, coreSpeed=2000), memory=Memory(memorySize=64000000), - cpuPowerModel=CPUPowerModel( - modelType="linear", + cpuPowerModel=MseCPUPowerModel( + modelType="mse", power=200, idlePower=20, maxPower=100, + calibrationFactor=0.5, ), ) ], diff --git a/pyproject.toml b/pyproject.toml index 0e27067..d4a2d10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,13 +14,10 @@ dependencies = [ # Validation "pydantic>=2.0.0", "pydantic-settings>=2.0.0", - # Web framework (for dashboard service) + # Web framework (for api service) "fastapi>=0.104.0", "uvicorn[standard]>=0.24.0", "jinja2>=3.1.0", - # Database (for services that need it) - "psycopg2-binary>=2.9.0", - "sqlalchemy>=2.0.0", # CLI tool "typer>=0.9.0", "rich>=13.0.0", @@ -75,7 +72,9 @@ select = [ "B", # flake8-bugbear "UP", # pyupgrade ] -ignore = [] +ignore = [ + "B008", # Allow function calls in argument defaults (FastAPI pattern for Query, Body, etc.) +] [tool.pytest.ini_options] testpaths = ["libs/common/tests"] diff --git a/reproducibility-capsule/README.md b/reproducibility-capsule/README.md new file mode 100644 index 0000000..51e80c4 --- /dev/null +++ b/reproducibility-capsule/README.md @@ -0,0 +1,59 @@ +# Reproducibility Capsule + +This folder contains everything needed to reproduce the experiments from the OpenDT paper. + +## Running Experiments + +OpenDT experiments are started using Docker Compose with a specific configuration file: + +```bash +# Experiment 1: Power prediction without calibration +make up config=config/experiments/experiment_1.yaml + +# Experiment 2: Power prediction with active calibration +make up config=config/experiments/experiment_2.yaml +``` + +### Experiment Duration + +The time required depends on the workload duration and the configured speed factor. With the SURF workload (~7 days of data) and `speed_factor: 300`, experiments complete in approximately **1 hour**. + +Monitor progress via Grafana at http://localhost:3000 or check logs: + +```bash +make logs-simulator +``` + +## Generating Plots + +Use the interactive plot generator to create publication-ready figures: + +```bash +python reproducibility-capsule/generate_plot.py +``` + +The script will: + +1. Ask which experiment to generate a plot for +2. Show available data sources (completed or in-progress runs) +3. Generate a PDF comparing Ground Truth, FootPrinter, and OpenDT +4. Display MAPE (Mean Absolute Percentage Error) for both FootPrinter and OpenDT + +You can run the plot generator **during** an experiment to visualize intermediate results. + +### Output + +Generated plots are saved to: + +``` +reproducibility-capsule/output/experiment__.pdf +``` + +## Baseline Data + +The `data/` folder contains pre-computed baseline results: + +- `footprinter.parquet` - Results from the FootPrinter simulator +- `real_world.parquet` - Ground truth power consumption data + +These are used as comparison baselines in the generated plots. diff --git a/reproducibility_capsule/exp_1/data/footprinter.parquet b/reproducibility-capsule/data/SURF/footprinter.parquet similarity index 100% rename from reproducibility_capsule/exp_1/data/footprinter.parquet rename to reproducibility-capsule/data/SURF/footprinter.parquet diff --git a/reproducibility-capsule/generate_plot.py b/reproducibility-capsule/generate_plot.py new file mode 100755 index 0000000..3087aa0 --- /dev/null +++ b/reproducibility-capsule/generate_plot.py @@ -0,0 +1,656 @@ +#!/usr/bin/env python3 +""" +Interactive plot generator for OpenDT reproducibility capsule. + +This script provides an interactive CLI to generate publication-ready plots +from OpenDT experiment runs. Users can select which experiment and data source +to use for generating the plots. + +Usage: + python generate_plot.py +""" + +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np +import pandas as pd +import yaml +from matplotlib.ticker import FuncFormatter +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +if TYPE_CHECKING: + from matplotlib.axes import Axes + +# Try to import matplotlib with a nice error message +try: + import matplotlib.pyplot as plt +except ImportError: + Console().print( + "[red]Error:[/red] matplotlib is required. Install with: pip install matplotlib" + ) + sys.exit(1) + +# --- Constants --- +REPO_ROOT = Path(__file__).parent.parent +DATA_DIR = REPO_ROOT / "data" +WORKLOAD_DIR = REPO_ROOT / "workload" +CAPSULE_DIR = Path(__file__).parent +CAPSULE_DATA_DIR = CAPSULE_DIR / "data" +OUTPUT_DIR = CAPSULE_DIR / "output" + +COLOR_PALETTE = [ + "#0072B2", # Blue (Ground Truth) + "#E69F00", # Orange (FootPrinter) + "#009E73", # Green (OpenDT) + "#D55E00", # Red-orange (MAPE rolling) + "#CC79A7", # Pink (MAPE cumulative) +] + +METRIC = "power_draw" + +console = Console() + + +# --- Data Discovery --- + + +def discover_runs() -> list[dict]: + """Discover all available experiment runs in the data directory.""" + runs = [] + + if not DATA_DIR.exists(): + return runs + + for run_dir in sorted(DATA_DIR.iterdir(), reverse=True): + if not run_dir.is_dir(): + continue + + # Check if it looks like a valid run (has config.yaml) + config_path = run_dir / "config.yaml" + metadata_path = run_dir / "metadata.json" + + if not config_path.exists(): + continue + + sim_results_path = run_dir / "simulator" / "agg_results.parquet" + run_info: dict = { + "path": run_dir, + "name": run_dir.name, + "has_simulator": sim_results_path.exists(), + "has_calibrator": (run_dir / "calibrator" / "agg_results.parquet").exists(), + "sim_duration": "โ€”", + "workload": "Unknown", + } + + # Parse timestamp from folder name (format: YYYY_MM_DD_HH_MM_SS) + try: + run_time = datetime.strptime(run_dir.name, "%Y_%m_%d_%H_%M_%S") + run_info["timestamp"] = run_time + run_info["time_ago"] = format_time_ago(run_time) + except ValueError: + run_info["timestamp"] = None + run_info["time_ago"] = "Unknown" + + # Try to read metadata for config source + if metadata_path.exists(): + try: + import json + + with open(metadata_path) as f: + metadata = json.load(f) + run_info["config_source"] = metadata.get("config_source", "Unknown") + except Exception: + run_info["config_source"] = "Unknown" + else: + run_info["config_source"] = "Unknown" + + # Read workload and calibration_enabled from config.yaml + try: + with open(config_path) as f: + config = yaml.safe_load(f) + workload = config.get("services", {}).get("dc-mock", {}).get("workload", "Unknown") + run_info["workload"] = workload + # Read calibration_enabled (defaults to False if not present) + calibration_enabled = config.get("global", {}).get("calibration_enabled", False) + run_info["calibration_enabled"] = calibration_enabled + except Exception: + run_info["calibration_enabled"] = None # Unknown + + # Try to read simulation duration from simulator results + if sim_results_path.exists(): + try: + df = pd.read_parquet(sim_results_path) + if "timestamp" in df.columns and len(df) > 1: + timestamps = pd.to_datetime(df["timestamp"]) + duration_minutes = (timestamps.max() - timestamps.min()).total_seconds() / 60 + run_info["sim_duration"] = format_duration(duration_minutes) + except Exception: + pass + + runs.append(run_info) + + return runs + + +def format_time_ago(dt: datetime) -> str: + """Format a datetime as a human-readable 'time ago' string.""" + now = datetime.now() + diff = now - dt + + seconds = diff.total_seconds() + if seconds < 60: + return "just now" + elif seconds < 3600: + minutes = int(seconds / 60) + return f"{minutes} minute{'s' if minutes != 1 else ''} ago" + elif seconds < 86400: + hours = int(seconds / 3600) + return f"{hours} hour{'s' if hours != 1 else ''} ago" + else: + days = int(seconds / 86400) + return f"{days} day{'s' if days != 1 else ''} ago" + + +def format_duration(minutes: float) -> str: + """Format a duration in minutes as a human-readable string. + + Examples: "7 days, 3 hours", "5 hours", "45 minutes" + """ + if minutes < 1: + return "< 1 minute" + + total_minutes = int(minutes) + days = total_minutes // (24 * 60) + remaining = total_minutes % (24 * 60) + hours = remaining // 60 + mins = remaining % 60 + + parts = [] + + if days > 0: + parts.append(f"{days} day{'s' if days != 1 else ''}") + # Only show hours if there are days + if hours > 0: + parts.append(f"{hours} hour{'s' if hours != 1 else ''}") + elif hours > 0: + parts.append(f"{hours} hour{'s' if hours != 1 else ''}") + # Only show minutes if less than a day and there are hours + if mins >= 30: + parts.append(f"{mins} min") + else: + parts.append(f"{mins} minute{'s' if mins != 1 else ''}") + + return ", ".join(parts) + + +# --- Interactive Selection --- + + +def select_experiment() -> int: + """Interactively select which experiment to generate a plot for.""" + console.print() + console.print( + Panel.fit( + "[bold cyan]OpenDT Reproducibility Plot Generator[/bold cyan]", + border_style="cyan", + ) + ) + console.print() + + experiments = [ + ("1", "Experiment 1: Predict power usage", "Without active calibration"), + ("2", "Experiment 2: Predict power usage with calibration", "With active calibration"), + ] + + console.print("[bold]Select an experiment:[/bold]") + console.print() + + for num, title, desc in experiments: + console.print(f" [cyan]{num}[/cyan]) [bold]{title}[/bold]") + console.print(f" [dim]{desc}[/dim]") + console.print() + + while True: + choice = console.input("[bold]Enter choice (1 or 2): [/bold]").strip() + if choice in ("1", "2"): + return int(choice) + console.print("[red]Invalid choice. Please enter 1 or 2.[/red]") + + +def select_data_source(runs: list[dict], experiment: int) -> dict | None: + """Interactively select which data source to use.""" + console.print() + console.print("[bold]Available data sources:[/bold]") + console.print() + + # Filter runs based on experiment requirements + if experiment == 1: + # Experiment 1: needs simulator data AND calibration_enabled=False + valid_runs = [ + r for r in runs if r["has_simulator"] and r.get("calibration_enabled") is False + ] + required = "simulator data with calibration disabled" + config_file = "experiment_1.yaml" + else: + # Experiment 2: needs simulator + calibrator data AND calibration_enabled=True + valid_runs = [ + r + for r in runs + if r["has_simulator"] and r["has_calibrator"] and r.get("calibration_enabled") is True + ] + required = "simulator + calibrator data with calibration enabled" + config_file = "experiment_2.yaml" + + if not valid_runs: + console.print(f"[red]No valid runs found with {required}.[/red]") + console.print() + console.print("[dim]To generate data for this experiment, run:[/dim]") + cmd = f"make up config=config/experiments/{config_file}" + console.print(f" [cyan]{cmd}[/cyan]") + console.print() + console.print("[dim]Then wait for the simulation to complete.[/dim]") + return None + + # Build table + table = Table(show_header=True, header_style="bold magenta", box=None) + table.add_column("#", style="cyan", width=4) + table.add_column("Run ID", style="bold") + table.add_column("Time", style="green") + table.add_column("Sim Duration", style="cyan") + table.add_column("Workload", style="yellow") + table.add_column("Data", style="dim") + + for i, run in enumerate(valid_runs, 1): + data_status = [] + if run["has_simulator"]: + data_status.append("sim") + if run["has_calibrator"]: + data_status.append("calib") + + table.add_row( + str(i), + run["name"], + run["time_ago"], + run.get("sim_duration", "โ€”"), + run.get("workload", "โ€”"), + " + ".join(data_status), + ) + + console.print(table) + console.print() + + while True: + choice = console.input( + f"[bold]Select data source (1-{len(valid_runs)}) or 'q' to quit: [/bold]" + ).strip() + + if choice.lower() == "q": + return None + + try: + idx = int(choice) - 1 + if 0 <= idx < len(valid_runs): + return valid_runs[idx] + except ValueError: + pass + + console.print(f"[red]Invalid choice. Enter a number 1-{len(valid_runs)}.[/red]") + + +# --- Data Loading & Processing --- + + +def load_baseline_data( + workload: str, +) -> tuple[pd.Series, pd.Series]: # type: ignore[type-arg] + """Load FootPrinter and real world consumption data. + + FootPrinter data is loaded from reproducibility-capsule/data//. + Real world data is loaded from workload//consumption.parquet. + + Returns: + Tuple of (footprinter_series, real_world_series) + """ + fp_data_dir = CAPSULE_DATA_DIR / workload + fp_path = fp_data_dir / "footprinter.parquet" + rw_path = WORKLOAD_DIR / workload / "consumption.parquet" + + if not fp_path.exists(): + console.print() + console.print(f"[red]Error: FootPrinter data not found: {fp_path}[/red]") + console.print(f"[dim]Expected file: {fp_path}[/dim]") + sys.exit(1) + + if not rw_path.exists(): + console.print() + console.print(f"[red]Error: Real world consumption data not found: {rw_path}[/red]") + console.print(f"[dim]Expected file: {rw_path}[/dim]") + sys.exit(1) + + # Load FootPrinter data + fp_df = pd.read_parquet(fp_path) + base_dt = pd.Timestamp("2022-10-06 22:00:00") + + # Handle timestamp conversion for footprinter + if "timestamp_absolute" in fp_df.columns: + fp_df["timestamp"] = pd.to_datetime(fp_df["timestamp_absolute"], unit="ms") + else: + fp_df["timestamp"] = base_dt + pd.to_timedelta(fp_df["timestamp"].values, unit="ms") + + fp_series: pd.Series = fp_df.groupby("timestamp")[METRIC].sum() # type: ignore[type-arg, assignment] + + # Load real world consumption data + rw_df = pd.read_parquet(rw_path) + + # Handle timestamp conversion for real world + if "timestamp_absolute" in rw_df.columns: + rw_df["timestamp"] = pd.to_datetime(rw_df["timestamp_absolute"], unit="ms") + else: + rw_df["timestamp"] = base_dt + pd.to_timedelta(rw_df["timestamp"].values, unit="ms") + + rw_series: pd.Series = rw_df.groupby("timestamp")[METRIC].sum() # type: ignore[type-arg, assignment] + + return fp_series, rw_series + + +def load_opendt_results(run_path: Path) -> pd.Series: # type: ignore[type-arg] + """Load OpenDT simulation results. + + For both experiments, we use the simulator's aggregated results. + The difference is: + - Experiment 1: Simulator runs without calibration + - Experiment 2: Simulator runs with calibration (receives calibrated topology) + """ + results_path = run_path / "simulator" / "agg_results.parquet" + + if not results_path.exists(): + raise FileNotFoundError(f"OpenDT results not found: {results_path}") + + df = pd.read_parquet(results_path) + + # Handle timestamp conversion + if "timestamp_absolute" in df.columns: + df["timestamp"] = pd.to_datetime(df["timestamp_absolute"], unit="ms", utc=True) + df["timestamp"] = df["timestamp"].dt.tz_localize(None) + else: + df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True) + df["timestamp"] = df["timestamp"].dt.tz_localize(None) + + result: pd.Series = df.groupby("timestamp")[METRIC].sum() # type: ignore[type-arg, assignment] + return result + + +def interpolate_to_1min(series: pd.Series) -> pd.Series: # type: ignore[type-arg] + """Interpolate power data to 1-minute intervals.""" + if len(series) < 2: + return series + + series = series.sort_index() + result: pd.Series = series.resample("1min").mean().interpolate(method="linear") # type: ignore[type-arg, assignment] + return result + + +def average_to_5min(series: pd.Series) -> pd.Series: # type: ignore[type-arg] + """Average 1-min data to 5-min intervals.""" + result: pd.Series = series.groupby(np.arange(len(series)) // 5).mean() # type: ignore[type-arg, assignment] + return result + + +def calculate_mape(ground_truth: pd.Series, simulation: pd.Series) -> float: # type: ignore[type-arg] + """Calculate Mean Absolute Percentage Error (MAPE).""" + r: np.ndarray = np.asarray(ground_truth.values) # type: ignore[type-arg] + s: np.ndarray = np.asarray(simulation.values) # type: ignore[type-arg] + # Avoid division by zero + mask = r != 0 + return float(np.mean(np.abs((r[mask] - s[mask]) / r[mask])) * 100) + + +def calculate_pointwise_mape(ground_truth: np.ndarray, simulation: np.ndarray) -> np.ndarray: # type: ignore[type-arg] + """Calculate point-wise absolute percentage error.""" + # Avoid division by zero + with np.errstate(divide="ignore", invalid="ignore"): + ape = np.abs((ground_truth - simulation) / ground_truth) * 100 + ape = np.nan_to_num(ape, nan=0.0, posinf=0.0, neginf=0.0) + return ape + + +# --- Plot Generation --- + + +def generate_plot( + run: dict, + experiment: int, + output_path: Path, +) -> tuple[float, float, int]: + """Generate experiment plot with Ground Truth, FootPrinter, OpenDT, and MAPE. + + Returns: + Tuple of (footprinter_mape, opendt_mape, sample_count) + """ + workload = run.get("workload", "SURF") + + console.print() + console.print("[bold]Loading data...[/bold]") + + # Load baseline data (FootPrinter and Real World) + fp, rw = load_baseline_data(workload) + odt = load_opendt_results(run["path"]) + + console.print(f" Ground Truth (real world): [green]{len(rw)}[/green] samples") + console.print(f" FootPrinter: [green]{len(fp)}[/green] samples") + console.print(f" OpenDT: [green]{len(odt)}[/green] samples") + + # Interpolate to 1-minute + console.print("[bold]Interpolating to 1-minute intervals...[/bold]") + rw_1min = interpolate_to_1min(rw) + fp_1min = interpolate_to_1min(fp) + odt_1min = interpolate_to_1min(odt) + + # Find common time range across all three + common_start = max(rw_1min.index[0], fp_1min.index[0], odt_1min.index[0]) + common_end = min(rw_1min.index[-1], fp_1min.index[-1], odt_1min.index[-1]) + + console.print(f" Common range: [cyan]{common_start}[/cyan] to [cyan]{common_end}[/cyan]") + + # Slice to common range + rw_1min = rw_1min[common_start:common_end] + fp_1min = fp_1min[common_start:common_end] + odt_1min = odt_1min[common_start:common_end] + + console.print(f" Aligned samples: [green]{len(rw_1min)}[/green]") + + # Calculate MAPE on 1-minute data + mape_fp = calculate_mape(rw_1min, fp_1min) # type: ignore[arg-type] + mape_odt = calculate_mape(rw_1min, odt_1min) # type: ignore[arg-type] + + # Calculate point-wise MAPE for OpenDT (for the MAPE line) + rw_vals = np.asarray(rw_1min.values) # type: ignore[union-attr] + odt_vals = np.asarray(odt_1min.values) # type: ignore[union-attr] + pointwise_mape = calculate_pointwise_mape(rw_vals, odt_vals) + + # Calculate cumulative MAPE + cumulative_mape = pd.Series(pointwise_mape).expanding().mean() + + # Average to 5-minute for plotting + rw_5min = average_to_5min(rw_1min) # type: ignore[arg-type] + fp_5min = average_to_5min(fp_1min) # type: ignore[arg-type] + odt_5min = average_to_5min(odt_1min) # type: ignore[arg-type] + + # Downsample MAPE to 5-minute + plot_len = len(rw_5min) + cumulative_mape_arr = np.asarray(cumulative_mape.values) # type: ignore[union-attr] + cumulative_mape_5min = cumulative_mape_arr[::5][:plot_len] + + # Create timestamps for plot + timestamps = pd.date_range(start=common_start, periods=plot_len, freq="5min") + rw_5min.index = timestamps + fp_5min.index = timestamps + odt_5min.index = timestamps + + # Generate plot + console.print("[bold]Generating plot...[/bold]") + + fig, ax1 = plt.subplots(figsize=(12, 6)) + ax1.grid(True, alpha=0.3) + + x = np.arange(plot_len) + + # Plot power lines on primary y-axis (thinner lines: lw=1.5) + rw_values = np.asarray(rw_5min.values) / 1000 + fp_values = np.asarray(fp_5min.values) / 1000 + odt_values = np.asarray(odt_5min.values) / 1000 + + line1 = ax1.plot(x, rw_values, label="Ground Truth", color=COLOR_PALETTE[0], lw=1.5) + line2 = ax1.plot(x, fp_values, label="FootPrinter", color=COLOR_PALETTE[1], lw=1.5) + line3 = ax1.plot(x, odt_values, label="OpenDT", color=COLOR_PALETTE[2], lw=1.5) + + # Create secondary y-axis for MAPE + ax2 = ax1.twinx() + + # Plot MAPE line (cumulative average) + line4 = ax2.plot( + x, + cumulative_mape_5min, + label="MAPE (OpenDT)", + color=COLOR_PALETTE[3], + lw=1.5, + linestyle="--", + alpha=0.8, + ) + + # Format X-axis + _format_time_axis(ax1, timestamps, plot_len) + + # Format primary Y-axis (Power) + y_formatter = FuncFormatter(lambda val, _: f"{int(val):,}") + ax1.yaxis.set_major_formatter(y_formatter) + ax1.tick_params(axis="y", labelsize=20) + ax1.set_ylabel("Power Draw [kW]", fontsize=22, labelpad=10) + ax1.set_xlabel("Time [day/month]", fontsize=22, labelpad=10) + ax1.set_ylim(0, 32) + + # Format secondary Y-axis (MAPE) + ax2.set_ylabel("MAPE [%]", fontsize=22, labelpad=10) + ax2.set_ylim(0, 10) + ax2.tick_params(axis="y", labelsize=20) + + # Combined legend - position it with more space from the plot + lines = line1 + line2 + line3 + line4 + labels = [str(line.get_label()) for line in lines] + ax1.legend( + lines, + labels, + fontsize=20, + loc="upper center", + bbox_to_anchor=(0.5, 1.24), + ncol=4, + framealpha=1, + ) + + plt.tight_layout() + plt.savefig(output_path, format="pdf", bbox_inches="tight") + plt.close() + + return mape_fp, mape_odt, len(rw_1min) + + +def _format_time_axis(ax: Axes, timestamps: pd.DatetimeIndex, plot_len: int) -> None: + """Format the x-axis with date labels.""" + target_dates = ["2022-10-08", "2022-10-10", "2022-10-12", "2022-10-14"] + tick_dates = pd.to_datetime(target_dates) + + tick_positions = [] + tick_labels = [] + + for d in tick_dates: + seconds_diff = (d - timestamps[0]).total_seconds() + idx = int(seconds_diff / 300) # 300 seconds = 5 minutes + + if 0 <= idx < plot_len: + tick_positions.append(idx) + tick_labels.append(d.strftime("%d/%m")) + + ax.set_xticks(tick_positions) + ax.set_xticklabels(tick_labels, fontsize=20) + + # Extend limit slightly to show last tick + if tick_positions: + max_tick = max(tick_positions) + if ax.get_xlim()[1] < max_tick: + ax.set_xlim(right=max_tick + (plot_len * 0.02)) + + +# --- Main --- + + +def main() -> None: + """Main entry point for the plot generator.""" + # Select experiment + experiment = select_experiment() + + # Discover available runs + runs = discover_runs() + + if not runs: + console.print() + console.print("[red]No experiment runs found in ./data directory.[/red]") + console.print("[dim]Run an experiment first with:[/dim]") + cmd = f"make up config=config/experiments/experiment_{experiment}.yaml" + console.print(f" [cyan]{cmd}[/cyan]") + return + + # Select data source + run = select_data_source(runs, experiment) + + if run is None: + console.print("[yellow]Cancelled.[/yellow]") + return + + console.print() + console.print(f"[bold green]โœ“[/bold green] Selected: [cyan]{run['name']}[/cyan]") + + # Get workload name for output path + workload = run.get("workload", "unknown") + + # Create output directory + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + # Generate output path: ./reproducibility-capsule/output/experiment__.pdf + output_path = OUTPUT_DIR / f"experiment_{experiment}_{workload}.pdf" + + # Generate plot + mape_fp, mape_odt, samples = generate_plot(run, experiment, output_path) + + # Print results + console.print() + console.print( + Panel.fit( + Text.assemble( + ("Results\n\n", "bold"), + ("FootPrinter MAPE: ", ""), + (f"{mape_fp:.2f}%", "bold yellow"), + ("\nOpenDT MAPE: ", ""), + (f"{mape_odt:.2f}%", "bold green"), + ("\n\nSamples: ", ""), + (f"{samples:,}", "cyan"), + (" (1-minute resolution)", "dim"), + ), + border_style="green", + ) + ) + + console.print() + console.print(f"[bold green]โœ“[/bold green] Plot saved to: [cyan]{output_path}[/cyan]") + console.print() + + +if __name__ == "__main__": + main() diff --git a/reproducibility-capsule/output/experiment_1_SURF.pdf b/reproducibility-capsule/output/experiment_1_SURF.pdf new file mode 100644 index 0000000..8e35ebe Binary files /dev/null and b/reproducibility-capsule/output/experiment_1_SURF.pdf differ diff --git a/reproducibility-capsule/output/experiment_2_SURF.pdf b/reproducibility-capsule/output/experiment_2_SURF.pdf new file mode 100644 index 0000000..a34e76f Binary files /dev/null and b/reproducibility-capsule/output/experiment_2_SURF.pdf differ diff --git a/reproducibility_capsule/exp_1/data/opendt.parquet b/reproducibility_capsule/exp_1/data/opendt.parquet deleted file mode 100644 index fb921c5..0000000 Binary files a/reproducibility_capsule/exp_1/data/opendt.parquet and /dev/null differ diff --git a/reproducibility_capsule/exp_1/data/real_world.parquet b/reproducibility_capsule/exp_1/data/real_world.parquet deleted file mode 100644 index 8467db7..0000000 Binary files a/reproducibility_capsule/exp_1/data/real_world.parquet and /dev/null differ diff --git a/reproducibility_capsule/exp_1/exp_1_cpu_latency/exp_1_cpu_latency.ipynb b/reproducibility_capsule/exp_1/exp_1_cpu_latency/exp_1_cpu_latency.ipynb deleted file mode 100644 index cc1aee9..0000000 --- a/reproducibility_capsule/exp_1/exp_1_cpu_latency/exp_1_cpu_latency.ipynb +++ /dev/null @@ -1,641 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "id": "initial_id", - "metadata": { - "collapsed": true, - "ExecuteTime": { - "end_time": "2025-11-30T17:33:06.585350Z", - "start_time": "2025-11-30T17:33:05.603873Z" - } - }, - "source": [ - "import pandas as pd\n", - "import numpy as np\n", - "\n", - "opendt = pd.read_parquet(\"../data/footprinter.parquet\")\n", - "metric_1 = \"cpu_utilization\"\n", - "metric_2 = \"carbon_intensity\"\n", - "opendt" - ], - "outputs": [ - { - "data": { - "text/plain": [ - " timestamp timestamp_absolute host_id \\\n", - "0 30000 1665093630000 3eb5d772-5f2d-a7e7-0000-00000000010e \n", - "1 30000 1665093630000 815f08b6-005e-b55f-0000-0000000000ec \n", - "2 30000 1665093630000 34c728aa-0584-7924-0000-0000000000c8 \n", - "3 30000 1665093630000 bdb9ee86-519b-4e29-0000-000000000085 \n", - "4 30000 1665093630000 58352289-bb0e-d97f-0000-0000000000c5 \n", - "... ... ... ... \n", - "5587085 605070000 1665698670000 d15dcc46-f84f-5c2a-0000-000000000084 \n", - "5587086 605070000 1665698670000 eb9d836a-3647-6587-0000-0000000000ca \n", - "5587087 605070000 1665698670000 a32dc9f6-4f1d-f03a-0000-000000000002 \n", - "5587088 605070000 1665698670000 44f70e6f-8f07-eca7-0000-0000000000c6 \n", - "5587089 605070000 1665698670000 97a96e0a-0d16-4dd9-0000-000000000093 \n", - "\n", - " host_name core_count mem_capacity guests_terminated \\\n", - "0 A01 16 128000000 0 \n", - "1 A01 16 128000000 0 \n", - "2 A01 16 128000000 0 \n", - "3 A01 16 128000000 0 \n", - "4 A01 16 128000000 0 \n", - "... ... ... ... ... \n", - "5587085 A01 16 128000000 0 \n", - "5587086 A01 16 128000000 0 \n", - "5587087 A01 16 128000000 0 \n", - "5587088 A01 16 128000000 0 \n", - "5587089 A01 16 128000000 0 \n", - "\n", - " guests_running guests_error guests_invalid ... cpu_time_steal \\\n", - "0 0 0 0 ... 0 \n", - "1 0 0 0 ... 0 \n", - "2 0 0 0 ... 0 \n", - "3 0 0 0 ... 0 \n", - "4 0 0 0 ... 0 \n", - "... ... ... ... ... ... \n", - "5587085 0 0 0 ... 0 \n", - "5587086 0 0 0 ... 0 \n", - "5587087 0 0 0 ... 0 \n", - "5587088 0 0 0 ... 0 \n", - "5587089 0 0 0 ... 0 \n", - "\n", - " cpu_time_lost power_draw energy_usage carbon_intensity \\\n", - "0 0 32.0 960.0 152.480506 \n", - "1 0 32.0 960.0 152.480506 \n", - "2 0 32.0 960.0 152.480506 \n", - "3 0 32.0 960.0 152.480506 \n", - "4 0 32.0 960.0 152.480506 \n", - "... ... ... ... ... \n", - "5587085 0 32.0 0.0 199.220248 \n", - "5587086 0 32.0 0.0 199.220248 \n", - "5587087 0 32.0 0.0 199.220248 \n", - "5587088 0 32.0 0.0 199.220248 \n", - "5587089 0 32.0 0.0 199.220248 \n", - "\n", - " carbon_emission uptime downtime boot_time boot_time_absolute \n", - "0 0.040661 30000 0 1665093600000 NaN \n", - "1 0.040661 30000 0 1665093600000 NaN \n", - "2 0.040661 30000 0 1665093600000 NaN \n", - "3 0.040661 30000 0 1665093600000 NaN \n", - "4 0.040661 30000 0 1665093600000 NaN \n", - "... ... ... ... ... ... \n", - "5587085 0.000000 0 0 1665093600000 NaN \n", - "5587086 0.000000 0 0 1665093600000 NaN \n", - "5587087 0.000000 0 0 1665093600000 NaN \n", - "5587088 0.000000 0 0 1665093600000 NaN \n", - "5587089 0.000000 0 0 1665093600000 NaN \n", - "\n", - "[5587090 rows x 26 columns]" - ], - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
timestamptimestamp_absolutehost_idhost_namecore_countmem_capacityguests_terminatedguests_runningguests_errorguests_invalid...cpu_time_stealcpu_time_lostpower_drawenergy_usagecarbon_intensitycarbon_emissionuptimedowntimeboot_timeboot_time_absolute
03000016650936300003eb5d772-5f2d-a7e7-0000-00000000010eA01161280000000000...0032.0960.0152.4805060.0406613000001665093600000NaN
1300001665093630000815f08b6-005e-b55f-0000-0000000000ecA01161280000000000...0032.0960.0152.4805060.0406613000001665093600000NaN
230000166509363000034c728aa-0584-7924-0000-0000000000c8A01161280000000000...0032.0960.0152.4805060.0406613000001665093600000NaN
3300001665093630000bdb9ee86-519b-4e29-0000-000000000085A01161280000000000...0032.0960.0152.4805060.0406613000001665093600000NaN
430000166509363000058352289-bb0e-d97f-0000-0000000000c5A01161280000000000...0032.0960.0152.4805060.0406613000001665093600000NaN
..................................................................
55870856050700001665698670000d15dcc46-f84f-5c2a-0000-000000000084A01161280000000000...0032.00.0199.2202480.000000001665093600000NaN
55870866050700001665698670000eb9d836a-3647-6587-0000-0000000000caA01161280000000000...0032.00.0199.2202480.000000001665093600000NaN
55870876050700001665698670000a32dc9f6-4f1d-f03a-0000-000000000002A01161280000000000...0032.00.0199.2202480.000000001665093600000NaN
5587088605070000166569867000044f70e6f-8f07-eca7-0000-0000000000c6A01161280000000000...0032.00.0199.2202480.000000001665093600000NaN
5587089605070000166569867000097a96e0a-0d16-4dd9-0000-000000000093A01161280000000000...0032.00.0199.2202480.000000001665093600000NaN
\n", - "

5587090 rows ร— 26 columns

\n", - "
" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 1 - }, - { - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-30T17:33:06.668348Z", - "start_time": "2025-11-30T17:33:06.611748Z" - } - }, - "cell_type": "code", - "source": [ - "# opendt_cpu_utilization = opendt[\"cpu_utilization\"].groupby(\"timestamp\").mean()\n", - "opendt_cpu = opendt.groupby(\"timestamp\")[\"cpu_utilization\"].mean()\n", - "opendt_cpu" - ], - "id": "a08588ee7796c0f5", - "outputs": [ - { - "data": { - "text/plain": [ - "timestamp\n", - "30000 0.232371\n", - "60000 0.214303\n", - "90000 0.214930\n", - "120000 0.215652\n", - "150000 0.217455\n", - " ... \n", - "604950000 0.052793\n", - "604980000 0.046264\n", - "605010000 0.035140\n", - "605040000 0.028653\n", - "605070000 0.008593\n", - "Name: cpu_utilization, Length: 20169, dtype: float64" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 2 - }, - { - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-30T17:33:06.800897Z", - "start_time": "2025-11-30T17:33:06.746596Z" - } - }, - "cell_type": "code", - "source": [ - "opendt_latency = opendt.groupby(\"timestamp\")[metric_2].sum()\n", - "opendt_latency" - ], - "id": "758199302a9a718f", - "outputs": [ - { - "data": { - "text/plain": [ - "timestamp\n", - "30000 42237.100185\n", - "60000 42237.100185\n", - "90000 42237.100185\n", - "120000 42237.100185\n", - "150000 42237.100185\n", - " ... \n", - "604950000 55184.008774\n", - "604980000 55184.008774\n", - "605010000 55184.008774\n", - "605040000 55184.008774\n", - "605070000 110368.017547\n", - "Name: carbon_intensity, Length: 20169, dtype: float64" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 3 - }, - { - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-30T17:37:38.701093Z", - "start_time": "2025-11-30T17:37:38.196625Z" - } - }, - "cell_type": "code", - "source": [ - "import pandas as pd\n", - "import numpy as np\n", - "import matplotlib.pyplot as plt\n", - "import matplotlib.dates as mdates\n", - "from matplotlib.ticker import FuncFormatter\n", - "\n", - "# --- STYLE CONFIGURATION ---\n", - "COLOR_PALETTE = [\n", - " \"#0072B2\", # Blue (for CPU)\n", - " \"#E69F00\", # Orange (for Latency)\n", - "]\n", - "\n", - "# 1. Load Data (Using your snippet)\n", - "# Note: Variable is 'opendt', but loading 'footprinter' file as per your code\n", - "opendt = pd.read_parquet(\"../data/footprinter.parquet\")\n", - "\n", - "# 2. Process Data\n", - "# Calculate Mean CPU and Sum Latency per timestamp\n", - "opendt_cpu = opendt.groupby(\"timestamp\")[metric_1].mean()\n", - "opendt_latency = opendt.groupby(\"timestamp\")[metric_2].sum()\n", - "\n", - "# --- Time Alignment ---\n", - "# Footprinter data is every 30 seconds.\n", - "# We create a date range starting from 2022-10-06 22:00:00 to match previous plots.\n", - "start_time = pd.Timestamp(\"2022-10-06 22:00:00\")\n", - "timestamps = pd.date_range(start=start_time, periods=len(opendt_cpu), freq=\"30S\")\n", - "\n", - "# 3. Plotting\n", - "fig, ax1 = plt.subplots(figsize=(12, 6))\n", - "\n", - "# --- LEFT AXIS (CPU) ---\n", - "color_cpu = COLOR_PALETTE[0]\n", - "ax1.set_xlabel(\"Time [day/month]\", fontsize=26, labelpad=10)\n", - "ax1.set_ylabel(\"Average CPU Utilization [%]\", color=color_cpu, fontsize=26, labelpad=10)\n", - "\n", - "# Plot CPU: Solid Blue Line with circle markers (using markevery to avoid clutter)\n", - "# 'markevery=len(x)//20' ensures we only see a few markers, like the reference style\n", - "line1 = ax1.plot(timestamps, opendt_cpu.values * 100,\n", - " color=color_cpu, linewidth=3, linestyle=\"-\",\n", - " marker=\"o\", markersize=8, markevery=len(timestamps)//15,\n", - " label=\"Avg CPU Utilization\")\n", - "\n", - "ax1.tick_params(axis='y', labelcolor=color_cpu, labelsize=28)\n", - "ax1.tick_params(axis='x', labelsize=28)\n", - "ax1.set_ylim(0, 110) # CPU % usually 0-100\n", - "\n", - "# --- RIGHT AXIS (LATENCYY) ---\n", - "ax2 = ax1.twinx() # Create a second y-axis sharing the same x-axis\n", - "color_latency = COLOR_PALETTE[1]\n", - "ax2.set_ylabel(\"Lantency [h]\", color=color_latency, fontsize=26, labelpad=10)\n", - "\n", - "# Plot Latency: Dashed Orange Line with square markers\n", - "line2 = ax2.plot(timestamps, opendt_latency.values,\n", - " color=color_latency, linewidth=3, linestyle=\"--\",\n", - " marker=\"s\", markersize=8, markevery=len(timestamps)//15,\n", - " label=\"Lantency [h]\")\n", - "\n", - "ax2.tick_params(axis='y', labelcolor=color_latency, labelsize=28)\n", - "ax2.set_ylim(bottom=0) # Ensure Latency axis starts at 0\n", - "\n", - "# --- FORMATTING ---\n", - "# X-Axis Date Format (Day/Month)\n", - "ax1.xaxis.set_major_formatter(mdates.DateFormatter('%d/%m'))\n", - "ax1.xaxis.set_major_locator(mdates.DayLocator(interval=2)) # Tick every 2 days\n", - "\n", - "# Legend (Combine lines from both axes)\n", - "lines = line1 + line2\n", - "labels = [l.get_label() for l in lines]\n", - "ax1.legend(lines, labels, loc=\"upper center\", fontsize=22, ncol=2, bbox_to_anchor=(0.5, 1.20))\n", - "\n", - "# Grid (Horizontal dashed lines)\n", - "ax1.grid(True, axis='y', linestyle='--', alpha=0.7)\n", - "\n", - "plt.tight_layout()\n", - "\n", - "# Save as PDF\n", - "plt.savefig(\"exp_1_power_draw.pdf\", format=\"pdf\", bbox_inches=\"tight\")\n", - "plt.show()" - ], - "id": "62f4d7951bf9ea7c", - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/var/folders/zp/wbw59jc53p912jytp6zlm1wr0000gs/T/ipykernel_21151/885497119.py:26: FutureWarning: 'S' is deprecated and will be removed in a future version, please use 's' instead.\n", - " timestamps = pd.date_range(start=start_time, periods=len(opendt_cpu), freq=\"30S\")\n" - ] - }, - { - "data": { - "text/plain": [ - "
" - ], - "image/png": "iVBORw0KGgoAAAANSUhEUgAABKYAAAJBCAYAAAB8j73mAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs3Qd4W+XVB/C/tuS94ixn70F2CCFAIBD2hpTRMspoC21pv5bS0lJGaUsLdEAHbWkZBVrK3huSMBKy996xneE9ZW19z3kdyZq2ZMuSbP9/z6PE9+rqvldX695zz3tejdfr9YKIiIiIiIiIiCjJtMlukIiIiIiIiIiISDAwRUREREREREREKcHAFBERERERERERpQQDU0RERERERERElBIMTBERERERERERUUowMEVERERERERERCnBwBQREREREREREaUEA1NERERERERERJQS+tQ0S5QevF4vnE4nPB5PqjeFiIiIiKhX0Wq1MBgM0Gg0qd4UIkpjDExRn2S1WlFfX4/Gxka43e5Ubw4RERERUa+k0+mQnZ2N3NxcZGRkpHpziCgNabySMkLUh0gwqqysTF29ycnJQWZmprqawys5RERERESJIaeZ0iuhubkZDQ0NqpdCSUmJClIREQViYIr6XKbUgQMHVEBq0KBBDEYRERERESUhSHXo0CEVoBo2bBgzp4goCIufU58i3fckU4pBKSIiIiKi5JCeCXL8LcfhcjxORBSIgSnqU1dqpBufZEux2x4RERERUfLI8bcch8vxOKvJEFEgBqaoz5B+7VLoXGpKERERERFRcknxczkel+NyIiIfBqaoz5Dii0IKnRMRERERUfJH6As8LiciEjxDpz6H3fiIiIiIiJKPx+FEFAkDU0RERERERERElBIMTBERERERERERUUowMEVERERERERERCnBwBQREREREREREaUEA1NERERxFG2V26mnnhrx/nvvvde/zJIlSzq1jp5s+PDh6rnJ/0RE1HXXX3+9/3cj8HbxxRd3uPz+/fu71HZdXV3Ettv7jSMi6gwGpogo7Tz00ENBBz8vvvgi+hqn04mXX34Zt9xyC6ZNm4aBAwfCZDIhOztbnfSff/75+NWvfoXdu3dHXUdgkCT0ptfrUVhYiJkzZ+Lb3/42VqxYEdN65O9YdfZxyWxf5svtqaee6vT29WZ//OMf1f6R/4mo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IiIiIiIi6vZR+YiIiIiIiIiIiBJJn9C1UZ/gcrmwbt069O/fH1otY5tERERERNR7eTweHD16FNOnT4dez1NookTjp4riJkGp448/nnuOiIiIiIj6jJUrV2L27Nmp3gyivhmY+nRPtfq/IMOAyQNzkAybDzegxupUf58yqjApbVJsJFPK98U8cOBA7jYiIiIiIuq1Dh8+rC7M+86DiCgFgalTH1sGDYCzxhXjnZvnIBl+9NZWfLCjEhqNBq6Hzk9KmxQbX/c9CUqVlJRwtxERERERUa/HMiZEfbArnxoukIMGEhERERERERH1SqxcTUREREREREREKcHAFBERERERERERpX9Xvvd3VEB3+5vdtzVERERERERERNRn6DtV9ykJpNg6ERERERERERH1Xvp0C0ilqj0iIiIiIiIiIkrDwNTiW+Z2/5YQEREREREREVGfElNgav6oou7fEiIiIiIiIiIi6lM4Kh8REREREREREaUEA1NERERERERERJQSDEwREREREREREVFKMDBFREREREREREQpwcAUERERERERERGl76h8RERERERERN3B3XIUDV9+O2he3inPQaMzcYcT9QEMTBEREREREVHKeF1W2A68HDLz36naHCJKMnblIyIiIiIiIiKilGBgioiIiIiIiIiIUoKBKSIiIiIiIiIiSgnWmEqi8voWrDxYh/J6G6wONwbnmjGuOAuzhuQlrI3Nhxuw+UijakOn1aAk14zpg3MxqigzYW0QERERERERESVCnw9MNdldWFtWrwJGK0trsaq0DvtrWvw7aFi+BfvvOqNLO3nlwVr8/N0d+GhXJTze8PtHF2XitpNG4DsnDYdGo+lUG8+tKcODi/dg4+GGiPefODwfdy8ci7PGF3dq/URERERERN1BZxmAgrM+CZ6p5Yh8RH1Fnw1M/X7pHjy5shRbjzZGDBYlysOL9+DOd7bB1U4ju6uacdtrm/Hm1iP43zUzkZ9hjHn9NqcbX39+PZ5ff6jd5Zbtr8U5/1yBH5wyEg9dMLHTATAiIiIiIqJE0ugtMA08jTuVqI/qs4GpT/dUqy5v3enxLw/gR29tDZo3bVAOThpRgEyjHtsqGvHOtgp/0OrDnVVY9O81eO/mOdDrYiv/ddMLG4KCUhJvWjimH6YOyoHD7VGZYMsP1Kr7vF7gd0v3qrbvO3tcAp8pERERERER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" - }, - "metadata": {}, - "output_type": "display_data", - "jetTransient": { - "display_id": null - } - } - ], - "execution_count": 17 - }, - { - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-30T17:33:07.782663Z", - "start_time": "2025-11-30T17:33:07.781417Z" - } - }, - "cell_type": "code", - "source": "", - "id": "79ca60394be72e01", - "outputs": [], - "execution_count": null - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 2 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython2", - "version": "2.7.6" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/reproducibility_capsule/exp_1/exp_1_cpu_latency/exp_1_power_draw.pdf b/reproducibility_capsule/exp_1/exp_1_cpu_latency/exp_1_power_draw.pdf deleted file mode 100644 index 990fb96..0000000 Binary files a/reproducibility_capsule/exp_1/exp_1_cpu_latency/exp_1_power_draw.pdf and /dev/null differ diff --git a/reproducibility_capsule/exp_1/exp_1_power_draw/exp1_plot_power_draw.ipynb b/reproducibility_capsule/exp_1/exp_1_power_draw/exp1_plot_power_draw.ipynb deleted file mode 100644 index afb5405..0000000 --- a/reproducibility_capsule/exp_1/exp_1_power_draw/exp1_plot_power_draw.ipynb +++ /dev/null @@ -1,266 +0,0 @@ -{ - "cells": [ - { - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-30T16:52:51.271852Z", - "start_time": "2025-11-30T16:52:50.710899Z" - } - }, - "cell_type": "code", - "source": [ - "import pandas as pd\n", - "import numpy as np\n", - "import matplotlib.pyplot as plt\n", - "from matplotlib.ticker import FuncFormatter\n", - "import os\n", - "\n", - "# --- 1. CONFIGURATION & STYLE ---\n", - "COLOR_PALETTE = [\n", - " \"#0072B2\", \"#E69F00\", \"#009E73\", \"#D55E00\", \"#CC79A7\", \"#F0E442\", \"#8B4513\",\n", - " \"#56B4E9\", \"#F0A3FF\", \"#FFB400\", \"#00BFFF\", \"#90EE90\", \"#FF6347\", \"#8A2BE2\",\n", - " \"#CD5C5C\", \"#4682B4\", \"#FFDEAD\", \"#32CD32\", \"#D3D3D3\", \"#999999\"\n", - "]\n", - "METRIC = \"power_draw\"" - ], - "id": "27fff5fd2bc818", - "outputs": [], - "execution_count": 1 - }, - { - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-30T16:52:51.278141Z", - "start_time": "2025-11-30T16:52:51.274862Z" - } - }, - "cell_type": "code", - "source": [ - "def load_and_process_data():\n", - " \"\"\"Loads parquet files and aligns timestamps.\"\"\"\n", - " print(\"Loading data...\")\n", - "\n", - " # Check if data exists (Warning only)\n", - " if not os.path.exists(\"../data/footprinter.parquet\"):\n", - " print(\"Warning: ../data/footprinter.parquet not found.\")\n", - "\n", - " # Load Data\n", - " fp = pd.read_parquet(\"../data/footprinter.parquet\").groupby(\"timestamp\")[METRIC].sum()\n", - " odt = pd.read_parquet(\"../data/opendt.parquet\").groupby(\"timestamp\")[METRIC].sum()\n", - " rw = pd.read_parquet(\"../data/real_world.parquet\").groupby(\"timestamp\")[METRIC].sum()\n", - "\n", - " # --- Processing ---\n", - " print(\"Processing and aligning data...\")\n", - "\n", - " def average_every_n(series, n):\n", - " return series.groupby(np.arange(len(series)) // n).mean()\n", - "\n", - " # Average to 5-min intervals\n", - " # OpenDT (2.5m) -> 2 samples = 5 min\n", - " # Others (30s) -> 10 samples = 5 min\n", - " odt = average_every_n(odt, 2)\n", - " fp = average_every_n(fp, 10)\n", - " rw = average_every_n(rw, 10)\n", - "\n", - " # Sync lengths (trim to shortest)\n", - " min_len = min(len(odt), len(fp), len(rw))\n", - " odt = odt.iloc[:min_len]\n", - " fp = fp.iloc[:min_len]\n", - " rw = rw.iloc[:min_len]\n", - "\n", - " # Force Start Time to 2022-10-06 22:00:00\n", - " start_time = pd.Timestamp(\"2022-10-06 22:00:00\")\n", - " timestamps = pd.date_range(start=start_time, periods=min_len, freq=\"5T\")\n", - "\n", - " # Apply clean timestamps\n", - " odt.index = timestamps\n", - " fp.index = timestamps\n", - " rw.index = timestamps\n", - "\n", - " return fp, odt, rw, timestamps, min_len\n", - "\n", - "def calculate_mape(ground_truth, simulation):\n", - " \"\"\"Calculates Mean Absolute Percentage Error (MAPE).\"\"\"\n", - " R = ground_truth.values\n", - " S = simulation.values\n", - " return np.mean(np.abs((R - S) / R)) * 100" - ], - "id": "6e62986650f476e2", - "outputs": [], - "execution_count": 2 - }, - { - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-30T16:52:51.283294Z", - "start_time": "2025-11-30T16:52:51.280169Z" - } - }, - "cell_type": "code", - "source": [ - "def generate_experiment_pdf(x, fp, odt, rw, timestamps, min_len):\n", - " \"\"\"Generates the final publication plot.\"\"\"\n", - " print(\"Generating final experiment PDF...\")\n", - "\n", - " # Setup Figure (12, 5 size)\n", - " plt.figure(figsize=(12, 5))\n", - " plt.grid(True)\n", - "\n", - " # Plot Lines (Thick lines: lw=3)\n", - " plt.plot(x, rw.values / 1000, label=\"Ground Truth\", color=COLOR_PALETTE[0], lw=3)\n", - " plt.plot(x, fp.values / 1000, label=\"FootPrinter\", color=COLOR_PALETTE[1], lw=3)\n", - " plt.plot(x, odt.values / 1000, label=\"OpenDT\", color=COLOR_PALETTE[2], lw=3)\n", - "\n", - " ax = plt.gca()\n", - "\n", - " # --- Formatting X-Axis (Fixed Dates) ---\n", - " target_dates = [\"2022-10-08\", \"2022-10-10\", \"2022-10-12\", \"2022-10-14\"]\n", - " tick_dates = pd.to_datetime(target_dates)\n", - "\n", - " tick_positions = []\n", - " tick_labels = []\n", - "\n", - " for d in tick_dates:\n", - " seconds_diff = (d - timestamps[0]).total_seconds()\n", - " # 300 seconds = 5 minutes\n", - " idx = int(seconds_diff / 300)\n", - "\n", - " tick_positions.append(idx)\n", - " tick_labels.append(d.strftime(\"%d/%m\"))\n", - "\n", - " ax.set_xticks(tick_positions)\n", - " # INCREASED FONT SIZE (was 14)\n", - " ax.set_xticklabels(tick_labels, fontsize=20)\n", - "\n", - " # Extend limit slightly to show last tick\n", - " max_tick = max(tick_positions)\n", - " if ax.get_xlim()[1] < max_tick:\n", - " ax.set_xlim(right=max_tick + (min_len * 0.02))\n", - "\n", - " # --- Formatting Y-Axis ---\n", - " y_formatter = FuncFormatter(lambda val, _: f\"{int(val):,}\")\n", - " ax.yaxis.set_major_formatter(y_formatter)\n", - " # INCREASED FONT SIZE (was 14)\n", - " ax.tick_params(axis='y', labelsize=20)\n", - "\n", - " # Labels (INCREASED FONT SIZE)\n", - " plt.ylabel(\"Power Draw [kW]\", fontsize=22, labelpad=10)\n", - " plt.xlabel(\"Time [day/month]\", fontsize=22, labelpad=10)\n", - " plt.ylim(bottom=0)\n", - "\n", - " # Legend (INCREASED FONT SIZE)\n", - " # Moved slightly higher (1.20) to make room for the larger text\n", - " plt.legend(fontsize=18, loc=\"upper center\", bbox_to_anchor=(0.5, 1.22), ncol=3, framealpha=1)\n", - "\n", - " plt.tight_layout()\n", - "\n", - " # Save\n", - " plt.savefig(\"exp1_plot_power_draw.pdf\", format=\"pdf\", bbox_inches=\"tight\")\n", - " plt.show()\n", - " plt.close()" - ], - "id": "6ecfbf910182a75c", - "outputs": [], - "execution_count": 3 - }, - { - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-30T16:52:51.876953Z", - "start_time": "2025-11-30T16:52:51.288106Z" - } - }, - "cell_type": "code", - "source": [ - "# 1. Load Data\n", - "fp, odt, rw, timestamps, min_len = load_and_process_data()\n", - "x = np.arange(min_len)\n", - "\n", - "# 2. Calculate Stats\n", - "mape_fp = calculate_mape(rw, fp)\n", - "mape_odt = calculate_mape(rw, odt)\n", - "print(f\"Stats Calculated (Not plotted) - FP: {mape_fp:.2f}%, ODT: {mape_odt:.2f}%\")\n", - "\n", - "# 3. Generate Plot\n", - "generate_experiment_pdf(x, fp, odt, rw, timestamps, min_len)\n", - "print(\"Done! File generated: 'experiment1.pdf'\")" - ], - "id": "1e31a5b8a166efdb", - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Loading data...\n", - "Processing and aligning data...\n", - "Stats Calculated (Not plotted) - FP: 7.86%, ODT: 5.33%\n", - "Generating final experiment PDF...\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/var/folders/zp/wbw59jc53p912jytp6zlm1wr0000gs/T/ipykernel_19539/30282531.py:35: FutureWarning: 'T' is deprecated and will be removed in a future version, please use 'min' instead.\n", - " timestamps = pd.date_range(start=start_time, periods=min_len, freq=\"5T\")\n" - ] - }, - { - "data": { - "text/plain": [ - "
" - ], - "image/png": "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MHhuVXj11VerKTExESdOnEBoaChatGjh9T5ERERERETnU/taEcBBz7dfg+1oEN/pfK4SEZ0V1v4J5J9ehaL0PTDVvRxBDa5y+9pEDfwFactvQt7hvxyul5ptEd0/YNA4UIJQvmjQoIGaiIiIiIiIKpvYoBC31/dHInrhMG7RbEZI84/P+3oRkdSFaopqY3bAXJQDrcFLt7ygaogZ/CeKc5ORd3QuoA9GUN3LXLKm6MIq17sxaNAgddqnTx9ViJyIiIiIiOhic0ntpojT5CPJbOme1RYn8Tt+QIimEBpTDCK6fgp9OGtJEl0oGq0OGq3nAJQ9bVAsghuP9/s60QUIQi1ZskSdXnnlleVZDBERERER0QUTbgjC2u6d8P2qd9AIZ9BPcwi68MaI6vctDLFd1B9gIiK6wEGouLg4JCUlIT4+vgJWhYiIiIiI6MJo0HIyHqw7EIVntkAXWh+GmHZ8K4iIKlMQqlGjRioIdfLkyYpbIyIiIiIiogtAH1ZfTURE5B/a8tx5zJgxMJvNmDlzZsWtERERERERERERBZxyBaFuu+021K1bF/Pnz8cPP/xQcWtFREREREREREQBpVxBqMjISPz555+oU6cOJk6ciAceeACJiYkVt3ZERERERERERBQQylUTatCgQbZg1OHDh/HOO++oqVatWiowFRwc7PX+Go0GCxcuLM8qEBERERERERFRoAehFi9erAJJwnoqNaKOHTumJm9kPut9iIiIiIiIiIgosJUrCGUNJvlyHRERERERERERVV3lCkIVFxdX3JoQEREREREREVHAKldhciIiIiIiIiIiIl8wCEVERERERERERH7HIBQREREREREREVX+wuTixIkTWLt2LU6fPo3k5GTV9S42NhbVqlVDt27dUL169Yp4GCIiIiIiIiIiqmpBqJycHHzwwQeYNm0a9u7d63Xe5s2b45ZbbsFtt92GoKCgc31IIiIiIiIiIiKqSsPxli9fjkaNGuGRRx5RASiz2ex12r17Nx544AE0adIEq1atqvhnQUREREREREREgZUJNXv2bFx11VXIy8tTASYhw++aNWuG+vXrIzo6Wl1/5swZJCYm2oJU4tixYxg0aBD++OMPDB06tOKfDRERERERERERXfxBKKn5NHnyZOTm5qrLEnSSDKfx48er4JM7UiPqm2++wdtvv41Dhw6p+06cOBHbt29XdaOIiIiIiIiIiCjwlWk43lNPPYWkpCSV+TR69Ghs2bIFd911l8cAlJBA07333qvmHTlypC2YJcsiIiIiIiIiIqKqwecgVHZ2Nr777jsVgGrbti1+/vlnhIeH+/xAERER+OWXX9CuXTs1PO/bb79Vxc2JiIiIiIiIiCjw+RyEmjlzJjIyMtT5N954AyaTqcwPJp3x5L5CliXLJCIiIiIiIiKiwOdzEGrt2rXqVLriDRky5JwfUO7buHFjdX7NmjXnvBwiIiIiIiIiIgrAINTGjRvVULw+ffqU+0H79u2rhuTJMomIiIiIiIiIKPD5HIQ6ePCgOm3fvn25H1TqQonExMRyL4uIiIiIiIiIiAIoCJWenq5OY2Jiyv2g1mVYl0lERERERERERIHN5yBUWlqarctdeVmXYS10TkREREREREREgc3nIFR+fr7lDlqf7+KR1JayXyYREREREREREQW28keUiIiIiIiIiIiISsEgFBERERERERER+Z2+rHe45ZZbMGXKlHI9aHZ2drnuT0REREREREREAR6EOn36tH/WhIiIiIiIiIiIAlaZglBms9l/a0JERERERERERAHL5yDUgQMH/LsmREREREREREQUsHwOQtWvX9+/a0JERERERERERAGL3fGIiIiIiIiIiMjvGIQiIiIiIiIiIiK/YxCKiIiIiIiIiIj8jkEoIiIiIiIiIiLyOwahiIiIiIiIiIjI7xiEIiIiIiIiIiIiv2MQioiIiIiIiIiI/I5BKCIiIiIiIiIi8ruAD0KdOnUKM2fOxFNPPYURI0YgLi4OGo1GTZMnTy7z8ubMmYMrrrgCderUgclkUqdyWa4nIiIiIiIiIiL39AhwNWrUqJDlFBcX45ZbbsG0adMcrj969Kia/vjjD9x000349NNPodUGfGyPiIiIiIiIiKhMqlS0pF69ehg6dOg53ffxxx+3BaA6duyI77//HmvWrFGncllMnToVTzzxRIWuMxERERERERERqnoQSjJ+dDodJk6ciMpKhuHNmDEDJ06cwMGDB1WmUlnt2bMHb7zxhjrfpUsXLF++HNdddx26du2qTpctW6auF6+//joSEhIq/HkQEREREREREVXZIJRebxnN179/f1RWzz77LEaPHl2uYXnvvPMOCgsL1fn3338fwcHBDreHhISo64XM9/bbb5dzrYmIiIiIiIiIAku5glDWwE54eDgCldlsxp9//qnOt2jRAj169HA7n1zfvHlzdV7ml/sREREREREREVEFBKHatGmjTvft24dAdeDAARw7dsynjC/r7VKoPDEx8bysHxERERERERFRwAehxo0bpzJ+fvjhh4DN/NmxY4ftvGRCeWN/+86dO/26XkREREREREREVSYIdcMNN6Bv377YunUrpkyZgkB05MgR2/k6dep4nbdu3bq284cPH/brehERERERERERXUwslcXPkUajUfWPxo8fjw8++AArV67EHXfcoQJTErAxmUy42GVkZNjOh4WFeZ03NDTUdj4zM9PjfHl5eWqySk9PV6cFBQVqIqIS1u8EvxtEVJG4bSEif+C2hYiqwraloBzrUa4glE6ns52X4Xjr16/HjTfeWKYglrXrXGWVm5trO280Gr3Oax90y8nJ8Tjfyy+/rLr2OZs/f77qtEdErhYsWMCXhYgqHLctROQP3LYQUSBvW7Kzsy9MEMq5DlQg1oUKCgqync/Pz/c6r312U3BwsMf5Hn30Udx///0OmVAylG/o0KGIiIgo9zoTBRKJssvG9pJLLoHBYLjQq0NEAYLbFiLitoWILhYFlew/kXU013kPQvXr109lMwWy8PBwn4bYiaysLJ+G7knGlLuhivJhqgwfKKLKiN8PIuK2hYguFtxvIaJA3rYYyrEO5QpCLV68GIHOvhi5fZFyd+yLkdsXKSciIiIiIiIiqurK1R2vKmjVqpXt/K5du7zOa397y5Yt/bpeREREREREREQXEwahStGwYUPUqlVLnV+yZInXeZcuXapOa9eujQYNGlTUe0REREREREREdNFjEKoUUvPq8ssvt2U6rVq1yu18cr01E0rmD/RaWUREREREREREFyQIVVxcjJ9//hk33XQTevXqhRYtWqBx48Yu823btg0rVqzA9u3bcbGYMmUKdDqdOn/33XcjJyfH4Xa5LNcLvV6v5iciIiIiIiIiogoqTG61fPlyTJw4EYmJibbrzGaz22ygX3/9Fc899xwiIiJw/PhxBAUFwZ+WLVuGhIQE2+WkpCTbebn+q6++cph/8uTJLsto1qwZHnzwQbzyyitYt24devfujYcfflgF2fbt24dXX30VGzduVPPKfE2bNvXrcyIiIiIiIiIiqnJBqPnz5+PSSy9FYWGhCjxJJlBYWBhSU1Pdzn/LLbeoIFR6ejpmz56NK6+8Ev40depUfP311x6DZzKVFoQSL774Ik6dOoUvvvhCBZyuu+46l3luvPFGvPDCCxW05kREREREREREgaNcw/Ek0DRu3DgUFBSowNNnn32mrvvyyy893qdmzZro0aOHOr9w4UJcLLRaLaZNm4ZZs2apmk9SrNxoNKpTuSwBNQl4yXxERERERERERFSBmVAffvghzpw5o7Kf5s6di549e/p0P6kZtXLlSmzYsAH+JsPtnIfclcfIkSPVREREREREREREvitX2o5k/0jdp7Fjx/ocgBLNmzdXp/v37y/PwxMRERERERERUVUIQu3Zs0edDh48uEz3i4qKUqdpaWnleXgiIiIiIiIiIqoKQSgpLi5iYmLKdD+pISVkGB8REREREREREQW+cgWhrMGn5OTkMt0vMTFRncbFxZXn4YmIiIiIiIiIqCoEoZo0aaJOpch4WUgRc6kl1b59+/I8PBERERERERERVYUg1NChQ2E2m/HLL7/gxIkTPt1n4cKF+Pfff9X5YcOGlefhiYiIiIiIiIioKgShbrnlFoSEhCArKwtXXXVVqYXGJWNq3Lhx6nx0dDQmTZpUnocnIiIiIiIiIqKLRLkqg9eoUQMvvfQSpkyZogJMzZs3x0033YSioiLbPLNnz8ahQ4cwZ84czJo1C8XFxWoo3jvvvIPQ0NCKeA5ERERERERERFTJlbs93T333INTp07h5Zdftp0KCTSJSy+91DavDN0Tzz77LCZMmFDehyYiIiIiIiIioqowHM/qhRdeUFlOHTt2VIEmT1ObNm0wc+ZMPPHEExXxsEREREREREREVFUyoayGDx+upm3btmHp0qVITExEamoqwsLCUKdOHfTv3x+dO3euqIcjIiIiIiIiIqKqGISykmwnmYiIiIiIiIiIiCp0OB4REREREREREZHfglDS8S4rK6s8iyAiIiIiIiIioiqgXMPxRo0aBYPBgC5dumDQoEFq6t27N4xGY8WtIRERERERERERXfTKXROqoKAAq1atUtNLL70Ek8mEnj172oJS3bp1g06nq5i1JSIiIiIiIiKiqjcc79VXX1Ud8UJDQ2E2m9WUm5uLxYsX46mnnkKfPn0QHR2NkSNH4o033sCGDRsqbs2JiIiIiIiIiKhqZEI9+OCDaiosLMTatWvxzz//qGnlypUqGCUyMzMxb948NYmoqCj079/flinVqlWrinkmREREREREREQUuMPx1EL0ejUET6bHH38ceXl5KhBlDUpJgEqG7YkzZ87gzz//VJNGo1EBLCIiIiIiIiIiCmzlGo7nidSFGjBgAJ577jksW7YMKSkpqpPejTfeqAJWwjp8j4iIiIiIiIiIAl+FZEJ5kpWVhaVLl9oyojZv3szAExERERERERFRFVShQaj8/HwsX77cFnRat26dbbidfdaT1IGy1oQiIiIiIiIiIqLAV64gVFFREdasWeNQkFzqQTkHnRo3bmwLOg0cOBDVq1cv/5oTEREREREREVHVCELFxMSo7nfOQae6deuqYJM18FSnTp3yrykREREREREREVXNIFRGRobqcCd69+6NSZMmqeCTZD4RERERERERERFVeE2oFStW4OTJk6oO1JAhQ1R3vLi4uIpaPBERERERERERXcTKFYR69NFHsWjRIlsB8oSEBOzbtw+ff/65ypBq3bq1Go43ePBg9O/fH+Hh4RW35kREREREREREVDWCUC+++KI6lbpQ//77r61A+ebNm1FcXIytW7di27ZteO+996DT6dCpUycVkJLAVJ8+fWAymSrqeRARERERERERUaAPxwsLC8OIESPUJM6cOYMlS5bYglI7duxQmVLSSW/t2rV45ZVXYDQa0bNnT3U7EREREREREREFtgqrCWUvOjoaY8aMUZM4deqUGrY3e/ZsfP/99yoglZeXpwJVREREREREREQU+PwShLKSIXmS/WTNiJLi5UVFRapelNls9udDExERERERERFRIAehtmzZgoULF6qgk9SJysjIsN1mH3gKCgpCr169KvrhiYiIiIiIiIgoEINQe/fuVQEnCTwtXrwYycnJboNOBoMB3bp1U0XJZZJ6UFIXioiIiIiIiIiIAl+5glD16tXD0aNH3QadrN3wBg4caOuGFxISUr61JSIiIiIiIiKiqheEOnLkiO281Hlq166dCjhJ4Kl///6IiIioiHUkIiIiIiIiIqKqHIRq0aKFLdNpwIABiI2Nrbg1IyIiIiIiIiKigFGuINSOHTsqbk2IiIiIiIiIiChgaS/0ChARERERERERUeArd3c8T1JTU5GRkYHw8HBERUX562GIiIiIiIiIiKgqZUJlZWXh/fffVzWiwsLCVH2oBg0aqFO5LNd/+OGHaj4iIiIiIiIiIqpaKiQItWDBAjRt2hRTpkzB0qVLkZ2dDbPZbJvkslx/zz33oFmzZvj7778r4mGJiIiIiIiIiKiqBKHmzJmD0aNH4+TJk7agU2hoKNq3b4/evXurU8mEst52/PhxjBo1CnPnzq2YZ0BERERERERERIEdhEpLS8MNN9yAgoICFWAaPny4ynhKT0/Hxo0b8e+//6pTmW/JkiUYMWKEup/MP2HCBDUfEREREREREREFvnIFoT7++GOkpKRAo9Hg6aefxuzZs9GnTx912Z5c7tu3L2bNmoVnn31WXXfmzBl1fyIiIiIiIiIiCnzlCkJJUEn06NFDBaF88eSTT6Jnz54qc2rmzJnleXgiIiIiIiIiIqoKQag9e/aoLKfrrruuTPezzi/3JyIiIiIiIiKiwFeuIFRqaqo6rVmzZpnuFx8f73B/IiIiIiIiIiIKbOUKQkVHR6vTY8eOlel+0iFPREVFlefhiYiIiIiIiIioKgShmjVrpmo7/fDDD2W6348//qhOmzdvXp6HJyIiIiIiIiKiqhCEGjlypDpdvXo1XnjhBZ/u8+KLL2LlypWqltSoUaPK8/BERERERERERFQVglC33347YmJi1HnpjnfZZZdh+fLlbueV6+X2p556yjaUT+5PRERERERERESBT1+eO0dGRmL69OkYM2YMioqKMGvWLDWFhISgcePGCA0NRVZWFvbv369OhQzf0+v1+N///oeIiIiKeh5ERERERERERBSoQSjrkLwZM2Zg8uTJOHnypLpOAk5bt261zSOBJ6saNWrg66+/xtChQ8v70EREREREREREVBWG41kNGzYMCQkJePfddzFgwACVCSWBJ+skGVFy/fvvv6/mYwCKiIiIiIiIiKhqKXcmlJUEmu6++241ifT0dGRkZCA8PJzD7oiIiIiIiIiIqrhzDkIdPXoUW7ZsQVpamqoN1bZtW9SpU8d2u9R7Ys0nIiIiIiIiIiI6pyDUmjVrcN9992HVqlUut/Xo0QNvv/02unXrxleXiIiIiIiIiIjOrSbU/PnzVW0nCUDZ13yyTitXrkT//v0xb968siyWiIiIiIiIiIgCnM9BKKnvNGnSJOTm5tq63TVp0gS9evVSp1Z5eXlqPqkJRUREREREREREVKYg1P/+9z+cPHkSGo0GXbp0wfbt27Fnzx4sW7ZMne7YscM2DO/06dNqfiIiIiIiIiIiojIFoebMmaNO4+Li1HC7li1bOtzeokULNU/16tUd5iciIiIiIiIiIvI5CCWd8CQLauLEiYiOjnY7j1wvt8twva1bt/LVJSIiIiIiIiKisgWhUlJS1GmHDh28zte+fXt1mpyc7OuiiYiIiIiIiIgowPkchMrKylKn4eHhXucLCwtTpzk5OeVdNyIiIiIiIiIiqmpBKCIiIiIiIiIionPFIBQREREREREREVW+IJQUJyciIiIiIiIiIioLfZnmBjBmzBif5pMOeTqdrtSAVmFhYVlXgYiIiIiIiIiIAj0IZQ0weQssWbOlvM1HRERERERERERVR5mCUL4ElRh4IiIiIiIiIiKicw5CFRcX+zorERERERERERGRA3bHIyIiIiIiIiIiv2MQioiIiIiIiIiI/I5BKCIiIiIiIiIi8jsGoYiIiIiIiIiIyO8YhCIiIiIiIiIiIr9jEIqIiIiIiIiIiPyOQSgiIiIiIiIiIvI7BqGIiIiIiIiIiMjvGIQiIiIiIiIiIiK/YxCKiIiIiIiIiIj8jkEoIiIiIiIiIiLyOwahiIiIiIiIiIjI7xiEIiIiIiIiIiIiv2MQioiIiIiIiIiI/I5BKCIiIiIiIiIi8jsGoYiIiIiIiIiIyO8YhCIiIiIiIiIiIr9jEIqIiIiIiIiIiPyOQagyOnjwIB544AG0aNECoaGhiImJQdeuXfH6668jOzvbP+8SEREREREREdFFTn+hV+BiMmPGDEyYMAHp6em26yTwtG7dOjVNnToVs2bNQpMmTS7oehIRERERERERVTbMhPLRxo0bce2116oAVFhYGF588UWsWLECCxcuxM0336zm2bNnD0aNGoWMjAx/vmdERERERERERBcdZkL56N5770VOTg70ej3mz5+Pnj172m4bNGgQmjZtioceekgFot58800888wz/nrPiIiIiIiIiIguOsyE8sGaNWvw77//qvM33nijQwDKSupEtWzZUp1/9913UVBQUNHvFRERERERERHRRYtBKB/88ccftvP/93//5/6F1GoxceJEdT41NRWLFi2qqPeIiIiIiIiIiOiixyCUD5YtW6ZOpRte586dPc7Xv39/2/nly5dXxPtDRERERERERBQQGITywc6dO9WpdL2TmlCetGjRwuU+RERERERERETEIFSpcnNzkZSUpM7XqVPH67zR0dEqW0ocPnyYny8iIiIiIiIiorPYHa8UGRkZtvNhYWGlza6CUFlZWcjMzPQ4T15enpqs0tLS1GlKSgoLmhM5kSL/2dnZSE5OhsFg4OtDRBWC2xYi8gduW4ioKmxbMs7GScxmc5nvyyCUD5lQVkajsdQX1GQyqdOcnByP87z88st49tlnXa5v2LBhqcsnIiIiIiIiIqoMwajIyMgy3YdBqFIEBQXZzufn55f6gloznIKDgz3O8+ijj+L++++3XS4uLlZZULGxsdBoNL68b0RVRnp6OurWrauGuEZERFzo1SGiAMFtCxFx20JEF4v0SvafSDKgJABVq1atMt+XQahShIeH2857G2JnJUPxShu6J9lS1owpq6ioqFKXTVSVyca2MmxwiSiwcNtCRNy2ENHFIqIS/ScqawaUFbvj+ZAJJRlK4siRI17nPXPmjC0IJVFKIiIiIiIiIiKyYBDKB61atVKnCQkJKCws9Djfrl27bOdbtmzpy6KJiIiIiIiIiKoEBqF80KdPH3UqWU7r16/3ON+SJUts53v37l0R7w9RlSdDV59++mmXIaxEROXBbQsR+QO3LUTEbYt3GvO59NSrYtasWYPu3bur87feeis++eQTl3mkuHibNm2wc+dOVd/p1KlTlaJ1IhERERERERFRZcBMKB9069YNffv2VeenTZuGlStXuszz5ptvqgCUuPfeexmAIiIiIiIiIiKyw0woH23cuFENscvJyVGd7x577DEMHDhQXf7hhx/w2WefqfmaNWuGdevWOXTVIyIiIiIiIiKq6hiEKoMZM2ZgwoQJSE9Pd3u7BKBmzZqFJk2aVNT7Q0REREREREQUEBiEKqODBw/i3XffVcGmI0eOwGg0qqDT1VdfjbvuugshISH+eaeIiIiIiIiIiC5iDEIREREREREREZHfsTA5EXnN/HvggQfQokULhIaGIiYmBl27dsXrr7+O7OzsUl856RL53HPPoVevXuq+0jFSukd27twZDz30EBITE8v06g8dOhQajUYV/7eXkJCA77//Hvfdd5+q3SYZiTKfTF999VWZHqOwsFB1wJRmBNWqVUNwcDAaN26sOmNu3769TMsiIsftwcyZM/HUU09hxIgRiIuLs31PJ0+eXOaXas6cObjiiitQp04d1RJdTuWyXF9W3LYQVe1ti+zT/Pbbb7j99tvVfk50dLTaZ4mNjUXPnj3xzDPP4MSJE2VeNynVIevx9ttvO3TU3rFjh9o/ueOOO9TjyTbMus6LFy8u02PIur/22mtqObKvJftrst8m+2+yH0dElWO/xfl726hRI9vyGjRoULW2LWYiIjf++usvc0REhFk2E+6mZs2amffu3evxtZs3b545Ojra4/1lCg4ONk+fPt2n1z89Pd1sNBrV/RYsWGC7fvHixV4f48svv/T5/T19+rS5a9euHpdlMpnMn3/+OT8vROfA2/d00qRJPi+nqKjIfOONN3pd3k033aTm8wW3LURVe9uyefNmc1hYmNflyCT7RD/88IPP67Vr1y7bfe33l7766iuvj7No0SKfH0OW27RpU6/rPGPGDJ+XR0QVv9/izgMPPOCwvPr161epbQszoYjIbTfIa6+9VhXhl26QL774IlasWIGFCxfi5ptvVvPs2bMHo0aNQkZGhsv99+/fjzFjxuDMmTPq8uWXX45ffvkFa9aswR9//KGOHkhEXrpLyvnly5eX+i7MmzcP+fn5iIiIQP/+/e0D6bbzWq0WrVu3Rrdu3cr8rhYVFaksirVr16rLV155pcqoWL16Nd577z1Ur14deXl5KiPqXDItiKhEvXr1VPbRuXj88ccxbdo0db5jx44qC1K2LXIql8XUqVPxxBNP+LQ8bluIqva2RfZ1MjMz1XnJpn755ZexYMECbNiwQW0f5Hdf9i9kvvHjx/u8DyANjYRkDtg3LbLfb5Fsq06dOqFt27YoK9n/kv2wvXv3qsuyfyb7abK/Jvttsv8m6yz7c5s2bSrz8omoYvZb3P3PeueddxAUFITw8PAy3z8gti3nHL4iooDVt29fFeXW6/XmFStWuNz+2muv2SLhTz/9tMvtd955p+32Bx980O1jvPfee7Z5Ro0aVeo6TZw4Uc179dVXO1y/Z88e8+uvv64yojIyMtR1kv1U1kyoadOm2e5zxx13uD0iYM0Ma9KkibmgoMCn5RKRxVNPPaWOmp04cUJdPnDgQJmPKO7evVttl+Q+Xbp0MWdnZzvcnpWVpa63br+8ZWtacdtCVLW3LcuXLzdfc8015u3bt3uc548//jBrNBq1zMaNG5uLi4tLXW6/fv3c7getXr1a7QOtXLnSnJOTo66TfamyZis8+eSTtvvIfpm752XdXvbv39+nZRJRxe63OCssLDR37txZLeO5555TGVBlzYQKhG0Lg1BE5LIBs254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+Y/X+954441qPmfcthAFnvJuW7788ssy3V+mp59+2mEZ999/v7p+4MCBPq1zWR5Lnp8nsh/WtGlTj/eV/bgZM2acw6tKRBW13+LLf5j69eu7vT1Qty0cjkdEbkkG0pYtW3Dfffep7AMZMifZSl26dLFlG0gE3dvwtq1bt+L+++9XKe1yVFGG0YSHh6NNmza47bbbsHbtWrz11lsea7b4uwOEM6k9JTUhPvroI3VkQIqSBgUFoVGjRiorSgq133TTTed1nYjIkWQjShq4DJeR+i1SrFzSx+VULktXzalTp6r5nHHbQkT+cKG2LbIfJvtjsl8m+2eynyb7a82bN1f7b7Ifd77KGhBRxZsRoNsWzdmIGRFRpZCRkaGCQdLWVIbtXXLJJRd6lYgoAHDbQkT+sHv3brRo0UKdl+ErzsOAiYi4bXHETCgiqlQk8CQBKMmYkmJ3RETcthBRZTVz5kx1KhkCDEAREbctpWNlXSKqVCT49PTTT6Nhw4ZqiA0REbctRFRZ1a1bV+23SHdgIiJuW0rH4XhEREREREREROR3HI5HRERERERERER+xyAUERERERERERH5HYNQRERERERERETkdwxCERERERERERGR3zEIRUREREREREREfscgFBERERERERER+R2DUERERERERERE5HcMQhERERERERERkd8xCEVERERERERERH7HIBQREREREREREfkdg1BEREREREREROR3DEIREREREREREZHfMQhFRERERERERER+xyAUERERERERERH5HYNQRERERERERETkdwxCERERkc0zzzwDjUajpsWLF1fZVyYxMdH2OjhPmzZtqrBlT548ucLWmS5+X331le2zIefLq0OHDm4/w/I9JyIiuhAYhCIiIgrQYElZJwZEqCzmzp3LgKUP308J+MhUlYO6REREVnrbOSIiIiJyMXDgQNxzzz22yw0bNuSrBODPP/9Ur0NsbCz69u3L18RDEOrZZ5+1XR4wYMB5fZ3efvttpKWlqfPbtm3Dk08+eV4fn4iIyBmDUERERBex6tWr4/fff/d4u/0fz9atW+OFF17wOG+9evXQqVMnDtVx87qMGTOmot6ygGA2m/HXX3+p86NGjYJOp7vQq0QeAqhWUVFRfI2IiOiCYxCKiIjoIhYSEuI1QGL/xzMuLo7BFKoQa9euxbFjx9R5BuiIiIjIV6wJRURERETnNBQvKCgIQ4cO5atHREREPmEQioiIiHzujueus9uJEyfw+OOPo02bNoiIiFAZV1Ij6KefflLDtpyHB958881o3ry5yuKSekIynKssRZvl8Z577jn06dMH8fHxMBqN6jF79eqlhhueOXPmgr6jq1evxvjx41G3bl0VpKlduzaGDx+uXo+y2LhxI1566SX1+kgdKnm9TCYTatasqQI/7777LjIzMz3ev2vXrup9kqFyhw8fLvXx5L1q3Lixuk9wcLDX1/GPP/5Qp0OGDEFoaKjDbQ0aNFDLkFORn5+PDz74QL0/1apVQ1hYGNq3b4/XXnsNWVlZDvc9efKk+gzK7ZGRkQgPD0ePHj0wdepUl8+SJ3v37sX999+vlhEdHa3egzp16uDSSy9VHeeKioq83l/qNlk/41Yy5FXeB3kv5T2oVasWxo4di6VLl7pdhnye5f72w+GkNpS7hgDynfJmz549uPvuu9GsWTP1GZDsxp49e6r3X15bIiKii4qZiIiIAtaiRYvkn7ua+vfvX+r8Tz/9tG1+ua+zAwcO2G6fNGmSedmyZebq1avbrnOebrnlFnNxcbG676effmrW6/Ue5/34449LXb93333XHBIS4nEZMkVHR5vnzp17jq+Y++fpK3n9tFqtx3W76qqrzHv27Cl12c8++6zX52idatWqZV69erXbZUybNs0231NPPVXqus+bN882/8SJEz3Ot3fvXtt8U6dOdbm9fv366jY5PX78uLlz584e179r167mM2fOqPutXLnSXKNGDY/zjhs3zvZZ8uT555/3+hmTqXXr1uaEhASPy5DviXXenJwc9Z55W95rr73m9XtX2iSfNasvv/zSdr2cnz59ujk4ONjjfXv27GlOS0vz+pq4Wyf5nBIREV0IrAlFRERE5+TQoUOqHpB035KsqP79+6usE6kX9PHHHyMnJwefffaZytqQDKlbb71VZSz95z//UVkqhYWFmDVrli1DSDrQSRZKixYt3D7eE088gRdffFGdl+ybq666Si1bsqlSUlKwcOFC/PrrryqDZ/To0fjnn3/Oa9c26URm3wntiiuuwIgRI1Q2z86dO/HFF1/gl19+QXFxcanLys7OVhlM3bp1Q+/evVUWjGTASBaPZM7MnDkTy5cvV3WZ5DE2bdqkMq/sXXfddXjggQeQmpqqHvupp57yWkD8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+Pbbb70uy76ukbesKRm2t337dp/XcciQIWjatKk6L0Eo+w59vg7Fk6GHMkSwMjEajbbhhTLU7qOPPvJavN3+eY8dO/a8DdX0ZaggERFRoGMQioiIiCo1Gfr1yiuv2LJJpDPb33//7fU+ko30zDPPYMuWLedlHe+66y4EBQWp82+99Zaq4+RMimBfe+21HrvYWXXt2tV2/vHHH3dbcHzRokWqzlNZX8fbbrvNNqzRGljq1KkTunTp4vW+1nmlYLp9V7bK4sEHH7RlHUn3P/tsJyvJkLr++uttHfFGjhyJdu3a+XW9GjZsaDu/YcMGvz4WERHRxYCFyYmIiKjSGzVqlOru9tRTT6lhYZdccgn69u2rgiIyFM5gMKguert378aKFSuwatUqFbCS7J/zoVGjRnjppZdw//33q0LfV155pZpGjBihajrt3LlTFQGX4I9c/9tvv3lc1o033oiXX35ZBa2kELt0rZs4cSLq16+PM2fOqACLBIUk6DJhwgR88803Pq/n5MmTVWArNzfX5yyobdu2Yd++fZVyKJ79sLrHHnsML7zwgnpu8rpfddVVGDp0qHr9pQ6UvP7W4Yc1atSw1cPyJ+lsKEE+CUBJ4FBea/lM2tf5kuGm0vGQiIioKmAQioiIiC4KkuEigZgpU6aoYMy///6rJk/kj35kZOR5W7/77rtPBcKef/55FQCTQJNzsOnqq69WXeq8BaGkxpQMtZMgimTvyJC7hx9+2GGekJAQfPLJJypLqixBqJiYGJWN9fXXX9teI8kO8iULSq/Xq2BgZSWvuwzNk2ClBAJ//vlnNTlr1aqVCu6dr6LkEpyU4YKyTtKZUCbn4vLWmmJERESBjsPxiIiI6KIhGUEHDx7E+++/r/7Y161bV2WRSCZUXFwcunXrpoacSfBBCny3bdv2vK7fs88+qzKxpNC3dKSToIh0bxs2bBh++OEH/PTTT2pdSyOZPJs3b1ZD7mRIlyxHAmoSQJFsq02bNuGGG244p3WU7CArCUDZ1y3yFoTq16+fyuyp7IFKCdpJQFDee3nN5LWTgJME0L788kv1ujZu3Pi8rZO89ytXrlTvlzyuBBCJiIiqKo2ZrTqIiIiIHMiwLWs9n0mTJuGrr74KmFdIampZA0syTKxjx44e5z169KgK9MnuohRcv+eee87jmlJFWrx4MQYOHKjOP/3006pmGhER0fnGTCgiIiKiKkJqUs2cOdNWR8lbAErIsDXr8crKWg+KiIiILh4MQhERERF5IfWTpLOcdZKhcBcryX6xdtuT2lqlsWZMdejQQdXjoouLvG/Wz601C4qIiOhCYmFyIiIiogCVkJCgJum0JxlQ06dPV9dLvaRrrrmm1PvPnTv3PKwlERERVRUMQhERERG56VD3+++/u31drLWiLgbSOU+KpduTwthSoFurZUJ8oHv77beRlpbmcn2LFi0uyPoQERExCEVERETkRAI1UsA7UMhwLOkQ17NnTxWUki57FPg4BI+IiCobdscjIiIiIiIiIiK/Yx42ERERERERERH5HYNQRERERERERETkdwxCERERERERERGR3zEIRUREREREREREfscgFBERERERERER+R2DUERERERERERE5HcMQhERERERERERkd8xCEVERERERERERH7HIBQREREREREREcHf/h/mhz9WU6DnlgAAAABJRU5ErkJggg==" - }, - "metadata": {}, - "output_type": "display_data", - "jetTransient": { - "display_id": null - } - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Done! File generated: 'experiment1.pdf'\n" - ] - } - ], - "execution_count": 4 - }, - { - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-30T16:52:51.881515Z", - "start_time": "2025-11-30T16:52:51.880290Z" - } - }, - "cell_type": "code", - "source": "", - "id": "8832df56b62475e", - "outputs": [], - "execution_count": null - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 2 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython2", - "version": "2.7.6" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/reproducibility_capsule/exp_1/exp_1_power_draw/exp1_plot_power_draw.pdf b/reproducibility_capsule/exp_1/exp_1_power_draw/exp1_plot_power_draw.pdf deleted file mode 100644 index b31a7b2..0000000 Binary files a/reproducibility_capsule/exp_1/exp_1_power_draw/exp1_plot_power_draw.pdf and /dev/null differ diff --git a/reproducibility_capsule/exp_1/exp_1_power_draw/exp1_plot_power_draw.py b/reproducibility_capsule/exp_1/exp_1_power_draw/exp1_plot_power_draw.py deleted file mode 100644 index 441b334..0000000 --- a/reproducibility_capsule/exp_1/exp_1_power_draw/exp1_plot_power_draw.py +++ /dev/null @@ -1,147 +0,0 @@ -import pandas as pd -import numpy as np -import matplotlib.pyplot as plt -from matplotlib.ticker import FuncFormatter -import os - -# --- 1. CONFIGURATION & STYLE --- -COLOR_PALETTE = [ - "#0072B2", "#E69F00", "#009E73", "#D55E00", "#CC79A7", "#F0E442", "#8B4513", - "#56B4E9", "#F0A3FF", "#FFB400", "#00BFFF", "#90EE90", "#FF6347", "#8A2BE2", - "#CD5C5C", "#4682B4", "#FFDEAD", "#32CD32", "#D3D3D3", "#999999" -] -METRIC = "power_draw" - - -def load_and_process_data(): - """Loads parquet files and aligns timestamps.""" - print("Loading data...") - - # Check if data exists (Warning only) - if not os.path.exists("../data/footprinter.parquet"): - print("Warning: ../data/footprinter.parquet not found.") - - # Load Data - fp = pd.read_parquet("../data/footprinter.parquet").groupby("timestamp")[METRIC].sum() - odt = pd.read_parquet("../data/opendt.parquet").groupby("timestamp")[METRIC].sum() - rw = pd.read_parquet("../data/real_world.parquet").groupby("timestamp")[METRIC].sum() - - # --- Processing --- - print("Processing and aligning data...") - - def average_every_n(series, n): - return series.groupby(np.arange(len(series)) // n).mean() - - # Average to 5-min intervals - # OpenDT (2.5m) -> 2 samples = 5 min - # Others (30s) -> 10 samples = 5 min - odt = average_every_n(odt, 2) - fp = average_every_n(fp, 10) - rw = average_every_n(rw, 10) - - # Sync lengths (trim to shortest) - min_len = min(len(odt), len(fp), len(rw)) - odt = odt.iloc[:min_len] - fp = fp.iloc[:min_len] - rw = rw.iloc[:min_len] - - # Force Start Time to 2022-10-06 22:00:00 - start_time = pd.Timestamp("2022-10-06 22:00:00") - # Fixed deprecation warning: used '5min' instead of '5T' - timestamps = pd.date_range(start=start_time, periods=min_len, freq="5min") - - # Apply clean timestamps - odt.index = timestamps - fp.index = timestamps - rw.index = timestamps - - return fp, odt, rw, timestamps, min_len - - -def calculate_mape(ground_truth, simulation): - """Calculates Mean Absolute Percentage Error (MAPE).""" - R = ground_truth.values - S = simulation.values - return np.mean(np.abs((R - S) / R)) * 100 - - -def generate_experiment_pdf(x, fp, odt, rw, timestamps, min_len): - """Generates the final publication plot.""" - print("Generating final experiment PDF...") - - # Setup Figure (12, 5 size) - plt.figure(figsize=(12, 5)) - plt.grid(True) - - # Plot Lines (Thick lines: lw=3) - plt.plot(x, rw.values / 1000, label="Ground Truth", color=COLOR_PALETTE[0], lw=3) - plt.plot(x, fp.values / 1000, label="FootPrinter", color=COLOR_PALETTE[1], lw=3) - plt.plot(x, odt.values / 1000, label="OpenDT", color=COLOR_PALETTE[2], lw=3) - - ax = plt.gca() - - # --- Formatting X-Axis (Fixed Dates) --- - target_dates = ["2022-10-08", "2022-10-10", "2022-10-12", "2022-10-14"] - tick_dates = pd.to_datetime(target_dates) - - tick_positions = [] - tick_labels = [] - - for d in tick_dates: - seconds_diff = (d - timestamps[0]).total_seconds() - # 300 seconds = 5 minutes - idx = int(seconds_diff / 300) - - tick_positions.append(idx) - tick_labels.append(d.strftime("%d/%m")) - - ax.set_xticks(tick_positions) - # INCREASED FONT SIZE (was 14) - ax.set_xticklabels(tick_labels, fontsize=20) - - # Extend limit slightly to show last tick - max_tick = max(tick_positions) - if ax.get_xlim()[1] < max_tick: - ax.set_xlim(right=max_tick + (min_len * 0.02)) - - # --- Formatting Y-Axis --- - y_formatter = FuncFormatter(lambda val, _: f"{int(val):,}") - ax.yaxis.set_major_formatter(y_formatter) - # INCREASED FONT SIZE (was 14) - ax.tick_params(axis='y', labelsize=20) - - # Labels (INCREASED FONT SIZE) - plt.ylabel("Power Draw [kW]", fontsize=22, labelpad=10) - plt.xlabel("Time [day/month]", fontsize=22, labelpad=10) - plt.ylim(bottom=0) - - # Legend - # FIXED: Cleaned 'loc' and ensured it matches bbox placement - plt.legend(fontsize=18, loc="upper center", bbox_to_anchor=(0.5, 1.22), ncol=3, framealpha=1) - - plt.tight_layout() - - # Save - plt.savefig("exp1_plot_power_draw.pdf", format="pdf", bbox_inches="tight") - print("Plot saved as 'exp1_plot_power_draw.pdf'") - # plt.show() # Uncomment if running in interactive mode/notebook - plt.close() - - -def main(): - # 1. Load Data - fp, odt, rw, timestamps, min_len = load_and_process_data() - x = np.arange(min_len) - - # 2. Calculate Stats - mape_fp = calculate_mape(rw, fp) - mape_odt = calculate_mape(rw, odt) - print(f"Stats Calculated (Not plotted) - FP: {mape_fp:.2f}%, ODT: {mape_odt:.2f}%") - - # 3. Generate Plot - generate_experiment_pdf(x, fp, odt, rw, timestamps, min_len) - print("Done!") - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/monitor_resources.sh b/scripts/monitor_resources.sh deleted file mode 100755 index feb6b8a..0000000 --- a/scripts/monitor_resources.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/bash -# OpenDT Resource Monitoring Script -# Usage: ./scripts/monitor_resources.sh - -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -while true; do - clear - echo -e "${GREEN}โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—${NC}" - echo -e "${GREEN}โ•‘ OpenDT Resource Monitor (M1 Max) โ•‘${NC}" - echo -e "${GREEN}โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" - echo "" - - echo -e "${YELLOW}=== Docker Container Stats ===${NC}" - docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}" - echo "" - - echo -e "${YELLOW}=== OpenDC Process Count ===${NC}" - CALIB_PROC=$(docker exec opendt-calibrator ps aux 2>/dev/null | grep -c "OpenDCExperimentRunner" || echo "0") - SIM_PROC=$(docker exec opendt-simulator ps aux 2>/dev/null | grep -c "OpenDCExperimentRunner" || echo "0") - - echo -e "Calibrator OpenDC processes: ${CALIB_PROC}" - echo -e "Simulator OpenDC processes: ${SIM_PROC}" - echo -e "Total OpenDC processes: $((CALIB_PROC + SIM_PROC))" - - if [ $((CALIB_PROC + SIM_PROC)) -gt 4 ]; then - echo -e "${RED}โš ๏ธ HIGH PROCESS COUNT - May cause CPU contention${NC}" - fi - echo "" - - echo -e "${YELLOW}=== Java Process Details ===${NC}" - echo "Calibrator Java processes:" - docker exec opendt-calibrator ps aux 2>/dev/null | grep "java" | grep -v grep | awk '{print $2, $3, $4, $11}' || echo "None" - echo "" - echo "Simulator Java processes:" - docker exec opendt-simulator ps aux 2>/dev/null | grep "java" | grep -v grep | awk '{print $2, $3, $4, $11}' || echo "None" - echo "" - - echo -e "${YELLOW}=== System Info ===${NC}" - echo "Host CPU cores: $(sysctl -n hw.ncpu 2>/dev/null || echo "Unknown")" - echo "Docker Memory Limit: 7.654 GiB" - echo "" - - echo -e "${YELLOW}=== Recent Logs ===${NC}" - echo "Last calibrator activity:" - docker logs opendt-calibrator 2>&1 | grep -E "(Starting calibration|drift|complete)" | tail -3 - echo "" - echo "Last simulator activity:" - docker logs opendt-simulator 2>&1 | grep -E "(Starting simulation|cached|complete)" | tail -3 - echo "" - - echo -e "${GREEN}Press Ctrl+C to exit | Refreshing every 3 seconds...${NC}" - sleep 3 -done diff --git a/scripts/opendt_cli.py b/scripts/opendt_cli.py index 0727d0f..1a725dd 100644 --- a/scripts/opendt_cli.py +++ b/scripts/opendt_cli.py @@ -7,6 +7,7 @@ import typer from rich.console import Console +from rich.panel import Panel from rich.table import Table app = typer.Typer( @@ -46,28 +47,26 @@ def init( - Creates metadata file - Saves run ID for docker-compose to use """ - console.print("\n[bold cyan]Initializing OpenDT...[/bold cyan]\n") + console.print() + console.print(Panel.fit("OpenDT Initialization", style="bold cyan")) + console.print() # Validate config file exists project_root = get_project_root() config_path = project_root / config if not config_path.exists(): - console.print(f"[bold red]Error:[/bold red] Config file not found: {config_path}") + console.print(f"[red]Error:[/red] Config file not found: {config_path}") raise typer.Exit(code=1) - console.print(f"[green]โœ“[/green] Config file found: {config_path}") - # Generate timestamp ID and formatted invocation time now_utc = datetime.now(UTC) timestamp = now_utc.strftime("%Y_%m_%d_%H_%M_%S") invocation_time = now_utc.replace(microsecond=0, tzinfo=None).isoformat() - console.print(f"[green]โœ“[/green] Generated run ID (UTC): {timestamp}") # Create directory structure data_dir = project_root / "data" / timestamp data_dir.mkdir(parents=True, exist_ok=True) - console.print(f"[green]โœ“[/green] Created directory: {data_dir.relative_to(project_root)}") # Copy config to data directory config_copy_path = data_dir / "config.yaml" @@ -75,42 +74,32 @@ def init( import shutil shutil.copy2(config_path, config_copy_path) - console.print( - f"[green]โœ“[/green] Copied config to: {config_copy_path.relative_to(project_root)}" - ) except Exception as e: - console.print(f"[bold red]Error:[/bold red] Failed to copy config: {e}") + console.print(f"[red]Error:[/red] Failed to copy config: {e}") raise typer.Exit(code=1) from e # Create metadata file metadata = { "run_id": timestamp, - "config_source": config, # Relative path to original config + "config_source": config, "invocation_time": invocation_time, } metadata_path = data_dir / "metadata.json" try: with open(metadata_path, "w") as f: json.dump(metadata, f, indent=2) - console.print( - f"[green]โœ“[/green] Created metadata: {metadata_path.relative_to(project_root)}" - ) except Exception as e: - console.print(f"[bold red]Error:[/bold red] Failed to create metadata: {e}") + console.print(f"[red]Error:[/red] Failed to create metadata: {e}") raise typer.Exit(code=1) from e # Save run ID to file for docker-compose run_id_file = get_run_id_file() try: run_id_file.write_text(timestamp) - console.print(f"[green]โœ“[/green] Saved run ID to: {run_id_file.name}") except Exception as e: - console.print(f"[bold red]Error:[/bold red] Failed to save run ID: {e}") + console.print(f"[red]Error:[/red] Failed to save run ID: {e}") raise typer.Exit(code=1) from e - # Generate .env file in run directory - config_path_relative = config_copy_path.relative_to(project_root) - # Check if calibration is enabled and read workload name try: import yaml @@ -126,13 +115,9 @@ def init( # Validate workload directory exists workload_dir = project_root / "workload" / workload_name if not workload_dir.exists(): - console.print( - f"[bold red]Error:[/bold red] Workload directory not found: {workload_dir}" - ) + console.print(f"[red]Error:[/red] Workload directory not found: {workload_dir}") raise typer.Exit(code=1) - console.print(f"[green]โœ“[/green] Workload directory found: {workload_name}") - except typer.Exit: raise except Exception as e: @@ -141,6 +126,8 @@ def init( profile_flag = "" workload_name = "SURF" + # Generate .env file in run directory + config_path_relative = config_copy_path.relative_to(project_root) try: env_content = f"""# OpenDT Run Environment # Generated by opendt_cli.py - Do not edit manually @@ -151,43 +138,46 @@ def init( WORKLOAD_NAME={workload_name} WORKLOAD_PATH=./workload/{workload_name} """ - # Write to run directory run_env_file = data_dir / ".env" run_env_file.write_text(env_content) - console.print( - f"[green]โœ“[/green] Generated environment file: {run_env_file.relative_to(project_root)}" - ) except Exception as e: - console.print(f"[bold red]Error:[/bold red] Failed to create .env file: {e}") + console.print(f"[red]Error:[/red] Failed to create .env file: {e}") raise typer.Exit(code=1) from e - console.print("\n[bold green]Initialization complete![/bold green]") - console.print(f"\nRun ID: [bold]{timestamp}[/bold]") - console.print(f"Data directory: [bold]{data_dir.relative_to(project_root)}[/bold]") - console.print(f"Config path: [bold]{config_path_relative}[/bold]") - console.print(f"Workload: [bold]{workload_name}[/bold]") - console.print(f"Calibration enabled: [bold]{calibration_enabled}[/bold]") - console.print("\nTo start services, run: [bold cyan]make up[/bold cyan]\n") + # Display summary + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column("Key", style="dim") + table.add_column("Value") + + table.add_row("Run ID", f"[bold]{timestamp}[/bold]") + table.add_row("Config", str(config_path.relative_to(project_root))) + table.add_row("Workload", workload_name) + table.add_row("Calibration", "enabled" if calibration_enabled else "disabled") + table.add_row("Data dir", str(data_dir.relative_to(project_root))) + + console.print(table) + console.print() @app.command() def status() -> None: """Show current run information.""" - console.print("\n[bold cyan]OpenDT Run Status[/bold cyan]\n") + console.print() + console.print(Panel.fit("OpenDT Run Status", style="bold cyan")) + console.print() run_id_file = get_run_id_file() if not run_id_file.exists(): - console.print("[yellow]No active run found.[/yellow]") - console.print( - "Initialize a new run with: [bold cyan]python scripts/opendt_cli.py init[/bold cyan]\n" - ) + console.print("[dim]No active run found.[/dim]") + console.print("Initialize a new run with: [cyan]python scripts/opendt_cli.py init[/cyan]") + console.print() return try: run_id = run_id_file.read_text().strip() except Exception as e: - console.print(f"[bold red]Error:[/bold red] Failed to read run ID: {e}") + console.print(f"[red]Error:[/red] Failed to read run ID: {e}") raise typer.Exit(code=1) from e project_root = get_project_root() @@ -196,62 +186,43 @@ def status() -> None: calibrator_dir = data_dir / "calibrator" # Create status table - table = Table(show_header=False, box=None) - table.add_column("Key", style="cyan") - table.add_column("Value", style="white") + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column("Key", style="dim") + table.add_column("Value") - table.add_row("Run ID", run_id) - table.add_row("Data Directory", str(data_dir.relative_to(project_root))) + table.add_row("Run ID", f"[bold]{run_id}[/bold]") + table.add_row("Data dir", str(data_dir.relative_to(project_root))) # Count simulator runs if simulator_dir.exists(): run_dirs = sorted( [d for d in simulator_dir.iterdir() if d.is_dir() and d.name.startswith("run_")] ) - table.add_row("Simulator Runs", str(len(run_dirs))) + table.add_row("Simulator runs", str(len(run_dirs))) if run_dirs: last_run = run_dirs[-1] - table.add_row(" Latest Run", last_run.name) - - # Check for metadata metadata_file = last_run / "metadata.json" if metadata_file.exists(): try: with open(metadata_file) as f: metadata = json.load(f) - table.add_row(" Task Count", str(metadata.get("task_count", "N/A"))) - table.add_row(" Sim Time", str(metadata.get("simulated_time", "N/A"))) + table.add_row(" Latest", last_run.name) + table.add_row(" Tasks", str(metadata.get("task_count", "โ€”"))) table.add_row(" Cached", str(metadata.get("cached", False))) except Exception: pass else: - table.add_row("Simulator Runs", "0 (directory not found)") + table.add_row("Simulator runs", "0") # Count calibrator runs if calibrator_dir.exists(): run_dirs = sorted( [d for d in calibrator_dir.iterdir() if d.is_dir() and d.name.startswith("run_")] ) - table.add_row("Calibrator Runs", str(len(run_dirs))) - - if run_dirs: - last_run = run_dirs[-1] - table.add_row(" Latest Run", last_run.name) - - # Check for metadata - metadata_file = last_run / "metadata.json" - if metadata_file.exists(): - try: - with open(metadata_file) as f: - metadata = json.load(f) - table.add_row(" Task Count", str(metadata.get("task_count", "N/A"))) - table.add_row(" Sim Time", str(metadata.get("simulated_time", "N/A"))) - table.add_row(" Cached", str(metadata.get("cached", False))) - except Exception: - pass + table.add_row("Calibrator runs", str(len(run_dirs))) else: - table.add_row("Calibrator Runs", "0 (directory not found)") + table.add_row("Calibrator runs", "0") console.print(table) console.print() @@ -263,16 +234,18 @@ def clean() -> None: run_id_file = get_run_id_file() if not run_id_file.exists(): - console.print("[yellow]No run ID file found.[/yellow]\n") + console.print("[dim]No run ID file found.[/dim]") + console.print() return try: run_id = run_id_file.read_text().strip() run_id_file.unlink() - console.print(f"[green]โœ“[/green] Removed run ID file (run: {run_id})") - console.print("[dim]Note: Data directories were not deleted.[/dim]\n") + console.print(f"Removed run ID file (run: {run_id})") + console.print("[dim]Note: Data directories were not deleted.[/dim]") + console.print() except Exception as e: - console.print(f"[bold red]Error:[/bold red] Failed to remove run ID file: {e}") + console.print(f"[red]Error:[/red] Failed to remove run ID file: {e}") raise typer.Exit(code=1) from e diff --git a/services/dashboard/Dockerfile b/services/api/Dockerfile similarity index 78% rename from services/dashboard/Dockerfile rename to services/api/Dockerfile index fe809c1..4bc587f 100644 --- a/services/dashboard/Dockerfile +++ b/services/api/Dockerfile @@ -5,7 +5,6 @@ WORKDIR /app # Install system dependencies RUN apt-get update && apt-get install -y \ gcc \ - postgresql-client \ && rm -rf /var/lib/apt/lists/* # Copy and install dependencies from root pyproject.toml @@ -17,7 +16,7 @@ COPY libs/common /app/libs/common RUN pip install --no-cache-dir -e /app/libs/common # Copy service code -COPY services/dashboard /app/services/dashboard +COPY services/api /app/services/api # Create non-root user for security RUN useradd -m -u 1000 opendt && \ @@ -26,9 +25,9 @@ RUN useradd -m -u 1000 opendt && \ USER opendt # Set working directory to service for proper module resolution -WORKDIR /app/services/dashboard +WORKDIR /app/services/api # Expose the API port EXPOSE 8000 -CMD ["uvicorn", "dashboard.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/services/api/README.md b/services/api/README.md new file mode 100644 index 0000000..f085bbe --- /dev/null +++ b/services/api/README.md @@ -0,0 +1,53 @@ +# api + +REST API for querying simulation data and controlling the datacenter topology. + +## Access + +- **API Docs (Swagger):** http://localhost:3001/docs +- **API Docs (ReDoc):** http://localhost:3001/redoc +- **Health Check:** http://localhost:3001/health + +## Endpoints + +### GET /health + +Service health status. + +### GET /api/power + +Query aligned power data from simulation and actual consumption. + +**Parameters:** +- `interval_seconds` (int, default: 60) - Sampling interval +- `start_time` (datetime, optional) - Start time filter + +**Returns:** Timeseries of `timestamp`, `simulated_power`, `actual_power` + +### GET /api/carbon_emission + +Query carbon emission data based on power draw and grid carbon intensity. + +**Parameters:** +- `interval_seconds` (int, default: 60) - Sampling interval +- `start_time` (datetime, optional) - Start time filter + +**Returns:** Timeseries of `timestamp`, `carbon_emission` + +### PUT /api/topology + +Update the simulated datacenter topology. + +Publishes the new topology to `sim.topology` Kafka topic. The simulator will use this topology for future simulations. + +## Data Sources + +The API reads from: +- `data//simulator/agg_results.parquet` - Simulation results +- `workload//consumption.parquet` - Actual power data + +## Logs + +``` +make logs-api +``` diff --git a/services/dashboard/dashboard/__init__.py b/services/api/api/__init__.py similarity index 100% rename from services/dashboard/dashboard/__init__.py rename to services/api/api/__init__.py diff --git a/services/api/api/carbon_query.py b/services/api/api/carbon_query.py new file mode 100644 index 0000000..aecfdc1 --- /dev/null +++ b/services/api/api/carbon_query.py @@ -0,0 +1,154 @@ +"""Carbon emission data query module for dashboard API.""" + +import logging +import os +from datetime import datetime +from pathlib import Path +from typing import Any + +import pandas as pd +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +class CarbonDataPoint(BaseModel): + """Single carbon emission data point.""" + + timestamp: datetime = Field(..., description="Timestamp (ISO 8601 format)") + carbon_intensity: float = Field(..., description="Carbon intensity in gCO2/kWh") + power_draw: float = Field(..., description="Power draw in Watts") + carbon_emission: float = Field(..., description="Carbon emission in gCO2/h") + + +class CarbonDataResponse(BaseModel): + """Response model for carbon emission query.""" + + data: list[CarbonDataPoint] + metadata: dict[str, Any] = Field( + default_factory=dict, description="Metadata about the query" + ) + + +class CarbonDataQuery: + """Query carbon emission data from simulation results.""" + + def __init__(self, run_id: str): + """Initialize carbon data query. + + Args: + run_id: The run ID to query data for + """ + self.run_id = run_id + + # Get data directory from environment + data_dir = Path(os.getenv("DATA_DIR", "/app/data")) + self.run_dir = data_dir / run_id + + # Path to aggregated simulation results + self.sim_results_path = self.run_dir / "simulator" / "agg_results.parquet" + + logger.info(f"Initialized CarbonDataQuery for run {run_id}") + logger.info(f"Simulation results: {self.sim_results_path}") + + def query( + self, interval_seconds: int = 60, start_time: datetime | None = None + ) -> CarbonDataResponse: + """Query carbon emission data at specified interval. + + Args: + interval_seconds: Sampling interval in seconds (default: 60) + start_time: Optional start time to filter data (default: None, uses all data) + + Returns: + CarbonDataResponse with carbon emission timeseries data + + Raises: + FileNotFoundError: If required data files don't exist + ValueError: If data is invalid + """ + # Load simulated data + if not self.sim_results_path.exists(): + raise FileNotFoundError( + f"Simulation results not found: {self.sim_results_path}" + ) + + df = pd.read_parquet(self.sim_results_path) + logger.info(f"Loaded {len(df)} simulation records") + + # Validate required columns + required_cols = ["timestamp", "power_draw", "carbon_intensity"] + missing = [col for col in required_cols if col not in df.columns] + if missing: + raise ValueError(f"Missing required columns: {missing}") + + # Ensure timestamp is datetime + df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True) + + # Calculate carbon emission: power_draw (W) * carbon_intensity (gCO2/kWh) / 1000 + # This gives gCO2/h (grams of CO2 per hour at current power draw) + df["carbon_emission"] = df["power_draw"] * df["carbon_intensity"] / 1000 + + # Sort by timestamp + df = df.sort_values("timestamp", ignore_index=True) + + # Filter by start time if provided + if start_time: + df = df.loc[df["timestamp"] >= start_time].copy() + + # Resample to specified interval + df = self._resample_data(df, interval_seconds) + + # Convert to response model + data_points = [] + for _, row in df.iterrows(): + timestamp = row["timestamp"] + if isinstance(timestamp, pd.Timestamp): + timestamp_dt = timestamp.to_pydatetime() + else: + timestamp_dt = pd.to_datetime(timestamp).to_pydatetime() # type: ignore[union-attr] + + data_points.append( + CarbonDataPoint( + timestamp=timestamp_dt, + carbon_intensity=float(row["carbon_intensity"]), + power_draw=float(row["power_draw"]), + carbon_emission=float(row["carbon_emission"]), + ) + ) + + metadata = { + "run_id": self.run_id, + "interval_seconds": interval_seconds, + "count": len(data_points), + "start_time": df["timestamp"].min().isoformat() if not df.empty else None, + "end_time": df["timestamp"].max().isoformat() if not df.empty else None, + } + + return CarbonDataResponse(data=data_points, metadata=metadata) + + def _resample_data(self, df: pd.DataFrame, interval_seconds: int) -> pd.DataFrame: + """Resample data to specified interval. + + Args: + df: Input dataframe with timestamp index + interval_seconds: Target interval in seconds + + Returns: + Resampled dataframe + """ + # Set timestamp as index + df = df.set_index("timestamp") + + # Resample and take mean of numeric columns + resampled = df[["power_draw", "carbon_intensity", "carbon_emission"]].resample( + f"{interval_seconds}s" + ).mean() + + # Remove NaN rows and reset index + resampled = resampled.dropna().reset_index() + + logger.info(f"Resampled to {len(resampled)} data points at {interval_seconds}s interval") + + return resampled + diff --git a/services/dashboard/dashboard/main.py b/services/api/api/main.py similarity index 71% rename from services/dashboard/dashboard/main.py rename to services/api/api/main.py index 8d93ac0..41396a1 100644 --- a/services/dashboard/dashboard/main.py +++ b/services/api/api/main.py @@ -1,4 +1,4 @@ -"""OpenDT Dashboard - Main FastAPI Application.""" +"""OpenDT API - FastAPI Application for datacenter simulation data.""" import logging import os @@ -7,44 +7,27 @@ from pathlib import Path from typing import Annotated -from fastapi import Body, FastAPI, HTTPException, Query, Request +from api.carbon_query import CarbonDataQuery, CarbonDataResponse +from api.power_query import PowerDataQuery, PowerDataResponse +from fastapi import Body, FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import HTMLResponse -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates +from fastapi.responses import RedirectResponse from odt_common import load_config_from_env -from odt_common.models.topology import ( - AsymptoticCPUPowerModel, - CPU, - Cluster, - Host, - Memory, - Topology, -) +from odt_common.models.topology import CPU, AsymptoticCPUPowerModel, Cluster, Host, Memory, Topology from odt_common.utils import get_kafka_producer from odt_common.utils.kafka import send_message -from dashboard.power_query import PowerDataQuery, PowerDataResponse - logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) -# Setup paths for static files and templates -BASE_DIR = Path(__file__).resolve().parent.parent -STATIC_DIR = BASE_DIR / "static" -TEMPLATES_DIR = BASE_DIR / "templates" - -# Initialize templates -templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) - @asynccontextmanager async def lifespan(app: FastAPI): """Lifespan context manager for startup and shutdown events.""" # Startup - logger.info("Starting OpenDT Dashboard service...") + logger.info("Starting OpenDT API service...") # Load configuration try: @@ -65,7 +48,7 @@ async def lifespan(app: FastAPI): yield # Shutdown - logger.info("Shutting down OpenDT Dashboard service...") + logger.info("Shutting down OpenDT API service...") if app.state.kafka_producer: app.state.kafka_producer.close() logger.info("Kafka producer closed") @@ -73,8 +56,8 @@ async def lifespan(app: FastAPI): # Create FastAPI application app = FastAPI( - title="OpenDT Dashboard", - description="Open Digital Twin - Web Dashboard and API for datacenter simulation", + title="OpenDT API", + description="Open Digital Twin - REST API for datacenter simulation", version="0.1.0", lifespan=lifespan, docs_url="/docs", @@ -84,25 +67,22 @@ async def lifespan(app: FastAPI): # Configure CORS app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:8000"], # Dashboard URL + allow_origins=["*"], # Allow all origins for API access allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) -# Mount static files -app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") - # ============================================================================ -# DASHBOARD +# ROOT REDIRECT # ============================================================================ -@app.get("/", response_class=HTMLResponse) -async def dashboard(request: Request): - """Serve the OpenDT dashboard UI.""" - return templates.TemplateResponse("index.html", {"request": request}) +@app.get("/", include_in_schema=False) +async def root(): + """Redirect root to API documentation.""" + return RedirectResponse(url="/docs") # ============================================================================ @@ -242,9 +222,7 @@ async def get_power_data( interval_seconds: int = Query( 60, gt=0, le=3600, description="Sampling interval in seconds (1-3600)" ), - start_time: datetime | None = Query( - None, description="Optional start time (ISO 8601 format)" - ), + start_time: datetime | None = Query(None, description="Optional start time (ISO 8601 format)"), ): """Query aligned power usage data from simulation and actual consumption. @@ -267,9 +245,7 @@ async def get_power_data( # Get run ID from environment run_id = os.getenv("RUN_ID") if not run_id: - raise HTTPException( - status_code=500, detail="RUN_ID environment variable not set" - ) + raise HTTPException(status_code=500, detail="RUN_ID environment variable not set") # Get workload context from config if not app.state.config: @@ -278,9 +254,10 @@ async def get_power_data( try: # Get workload directory (mounted directly to specific workload) workload_dir = Path(os.getenv("WORKLOAD_DIR", "/app/workload")) - + # Create workload context directly with mounted workload directory from odt_common.config import WorkloadContext + workload_context = WorkloadContext(workload_dir=workload_dir) # Initialize query @@ -289,21 +266,75 @@ async def get_power_data( # Execute query result = query.query(interval_seconds=interval_seconds, start_time=start_time) - logger.info( - f"Power data query successful: {result.metadata['count']} data points" - ) + logger.info(f"Power data query successful: {result.metadata['count']} data points") return result except FileNotFoundError as e: logger.error(f"Data file not found: {e}") - raise HTTPException(status_code=404, detail=str(e)) + raise HTTPException(status_code=404, detail=str(e)) from e except ValueError as e: logger.error(f"Invalid data: {e}") - raise HTTPException(status_code=400, detail=str(e)) + raise HTTPException(status_code=400, detail=str(e)) from e except Exception as e: logger.error(f"Error querying power data: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") from e + + +# ============================================================================ +# CARBON EMISSION DATA QUERY +# ============================================================================ + + +@app.get("/api/carbon_emission", response_model=CarbonDataResponse) +async def get_carbon_emission_data( + interval_seconds: int = Query( + 60, gt=0, le=3600, description="Sampling interval in seconds (1-3600)" + ), + start_time: datetime | None = Query(None, description="Optional start time (ISO 8601 format)"), +): + """Query carbon emission data from simulation results. + + This endpoint reads from `agg_results.parquet` and calculates carbon emission + based on power draw and carbon intensity. + + Carbon emission formula: power_draw (W) * carbon_intensity (gCO2/kWh) / 1000 = gCO2/h + + Args: + interval_seconds: Sampling interval in seconds (default: 60) + start_time: Optional start time filter (ISO 8601 format) + + Returns: + CarbonDataResponse with carbon emission timeseries data + + Raises: + HTTPException: If data files are not found or cannot be processed + """ + # Get run ID from environment + run_id = os.getenv("RUN_ID") + if not run_id: + raise HTTPException(status_code=500, detail="RUN_ID environment variable not set") + + try: + # Initialize query + query = CarbonDataQuery(run_id=run_id) + + # Execute query + result = query.query(interval_seconds=interval_seconds, start_time=start_time) + + logger.info(f"Carbon emission query successful: {result.metadata['count']} data points") + + return result + + except FileNotFoundError as e: + logger.error(f"Data file not found: {e}") + raise HTTPException(status_code=404, detail=str(e)) from e + except ValueError as e: + logger.error(f"Invalid data: {e}") + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + logger.error(f"Error querying carbon emission data: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") from e if __name__ == "__main__": diff --git a/services/dashboard/dashboard/power_query.py b/services/api/api/power_query.py similarity index 100% rename from services/dashboard/dashboard/power_query.py rename to services/api/api/power_query.py diff --git a/services/calibrator/README.md b/services/calibrator/README.md new file mode 100644 index 0000000..85a5015 --- /dev/null +++ b/services/calibrator/README.md @@ -0,0 +1,88 @@ +# calibrator + +Optimizes topology parameters by comparing simulation output against actual power measurements. + +## Purpose + +The calibrator performs grid search over topology parameters (e.g., `asymUtil`) to find values that minimize the error between predicted and actual power consumption. It publishes the best-performing topology to Kafka for use by the simulator. + +## How It Works + +1. **Accumulate tasks** - Collect tasks from `dc.workload` over a calibration window +2. **Track power** - Record actual power consumption from `dc.power` +3. **Grid search** - Run parallel simulations with different parameter values +4. **Compare** - Calculate MAPE against actual power for each simulation +5. **Select** - Choose the parameter value with lowest error +6. **Publish** - Send calibrated topology to `sim.topology` + +## Calibrated Properties + +The calibrator can tune any numeric topology parameter. Common targets: + +| Property | Path | Description | +|----------|------|-------------| +| asymUtil | cpuPowerModel.asymUtil | Asymptotic utilization coefficient | + +## Components + +| File | Purpose | +|------|---------| +| main.py | Service orchestration | +| calibration_engine.py | Parallel OpenDC simulations | +| mape_comparator.py | Error calculation | +| power_tracker.py | Actual power tracking | +| topology_manager.py | Topology subscription and publishing | +| result_processor.py | Results aggregation | + +## Configuration + +Enable calibration in the config file: + +```yaml +global: + calibration_enabled: true +``` + +The calibrator only runs when this flag is set. + +Settings under `services.calibrator` in the config file: + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| calibrated_property | string | - | Topology property path to calibrate (e.g., `cpuPowerModel.calibrationFactor`) | +| min_value | float | - | Minimum value in the search space | +| max_value | float | - | Maximum value in the search space | +| linspace_points | int | 10 | Number of candidate values to evaluate | +| max_parallel_workers | int | 4 | Maximum parallel calibration simulations | +| mape_window_minutes | int | 60 | Time window for MAPE calculation | + +### calibrated_property + +Uses dot notation to specify nested properties in the topology JSON. + +### linspace_points + +Creates evenly-spaced values between `min_value` and `max_value`. Higher values improve accuracy but increase calibration time. + +### max_parallel_workers + +Controls parallelism during calibration. Set based on available CPU cores. + +## Output + +Results are written to `data//calibrator/`: + +``` +calibrator/ +โ”œโ”€โ”€ agg_results.parquet # Calibration metadata and MAPE values +โ””โ”€โ”€ opendc/ + โ””โ”€โ”€ run_/ # Simulation archives for each calibration run +``` + +## Logs + +``` +make logs-calibrator +``` + +Note: The calibrator runs with the `calibration` Docker Compose profile. It will not start unless `calibration_enabled: true` is set. diff --git a/services/dashboard/README.md b/services/dashboard/README.md deleted file mode 100644 index f72352d..0000000 --- a/services/dashboard/README.md +++ /dev/null @@ -1,296 +0,0 @@ -# dashboard Service - -The **dashboard** service provides a web-based user interface and REST API for the OpenDT system. It combines real-time visualization with programmatic control through FastAPI endpoints. - - -## Architecture - -``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ dashboard โ”‚ -โ”‚ โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ FastAPI โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ Kafka โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ Producer โ”‚ โ”‚ -โ”‚ โ”‚ Routes: โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ - / โ”‚ โ”‚ -โ”‚ โ”‚ - /health โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ - /docs โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ PostgreSQL โ”‚ โ”‚ -โ”‚ โ”‚ - /api/... โ”‚ โ”‚ Database โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ -โ”‚ Topics Published: โ”‚ -โ”‚ โ€ข sim.topology (Topology updates) โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` - -## Web Dashboard - -### Accessing the Dashboard - -Once services are running, access the dashboard at: -- **Dashboard**: http://localhost:8000 -- **API Docs**: http://localhost:8000/docs -- **Health Check**: http://localhost:8000/health - -### Dashboard Features - -The web UI provides: -- Real-time metrics display -- System status monitoring -- Interactive controls -- Power consumption charts (via Plotly.js) -- Topology visualization - -**Note**: The dashboard JavaScript polls API endpoints that are not yet fully implemented. Some features may show as unavailable until backend endpoints are added. - -## API Endpoints - -### Root - -**GET /** - -Serves the web dashboard UI. - -**Response**: HTML page with dashboard interface - ---- - -### Health Check - -**GET /health** - -Service health status including Kafka connectivity. - -**Response**: -```json -{ - "status": "healthy", - "kafka": "connected", - "config": "loaded" -} -``` - ---- - -### API Documentation - -**GET /docs** - -Interactive Swagger UI for testing endpoints. - -**GET /redoc** - -Alternative ReDoc documentation interface. - ---- - -### Update Topology - -**PUT /api/topology** - -Updates the simulated datacenter topology for What-If analysis. - -**Request Body** (`application/json`): -```json -{ - "clusters": [ - { - "name": "A01", - "hosts": [ - { - "name": "A01-Host", - "count": 277, - "cpu": { - "coreCount": 16, - "coreSpeed": 2100 - }, - "memory": { - "memorySize": 128000000 - }, - "cpuPowerModel": { - "modelType": "asymptotic", - "power": 400.0, - "idlePower": 32.0, - "maxPower": 180.0, - "asymUtil": 0.3, - "dvfs": false - } - } - ] - } - ] -} -``` - -**Response** (200 OK): -```json -{ - "status": "updated", - "message": "Topology published to sim.topology", - "clusters": 1, - "total_hosts": 277, - "total_cores": 4432, - "topic": "sim.topology" -} -``` - -**Behavior**: -1. Validates topology against `Topology` Pydantic model -2. Publishes to `sim.topology` Kafka topic (compacted) -3. `sim-worker` consumes update and: - - Updates simulated topology in memory - - Clears result cache (forces fresh simulations) - - Uses new topology for subsequent windows - -**Example via cURL**: -```bash -curl -X PUT http://localhost:8000/api/topology \ - -H "Content-Type: application/json" \ - -d @data/SURF/topology.json -``` - -## Configuration - -### Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `CONFIG_FILE` | Path to YAML configuration | `/app/config/simulation.yaml` | -| `DATABASE_URL` | PostgreSQL connection string | `postgresql://opendt:...` | -| `LOG_LEVEL` | Logging level | `INFO` | - -### YAML Configuration - -**File**: `config/default.yaml` - -```yaml -kafka: - bootstrap_servers: "kafka:29092" - topics: - sim_topology: - name: "sim.topology" - config: - cleanup.policy: "compact" - min.compaction.lag.ms: "0" -``` - -## Running - -### Via Docker Compose - -```bash -# Start all services -make up - -# Access dashboard -open http://localhost:8000 - -# View logs -make logs-dashboard -# Or: -docker compose logs -f dashboard -``` - -### Standalone (Development) - -```bash -cd services/dashboard -source ../../.venv/bin/activate - -# Set environment -export CONFIG_FILE=../../config/default.yaml -export DATABASE_URL=postgresql://opendt:opendt_dev_password@localhost:5432/opendt - -# Run with hot reload -uvicorn dashboard.main:app --reload --host 0.0.0.0 --port 8000 -``` - -## Development - -### Project Structure - -``` -dashboard/ -โ”œโ”€โ”€ __init__.py -โ”œโ”€โ”€ main.py # FastAPI app + routes -โ”œโ”€โ”€ static/ # Dashboard assets -โ”‚ โ”œโ”€โ”€ js/ -โ”‚ โ”‚ โ”œโ”€โ”€ charts.js -โ”‚ โ”‚ โ”œโ”€โ”€ polling.js -โ”‚ โ”‚ โ”œโ”€โ”€ ui.js -โ”‚ โ”‚ โ””โ”€โ”€ ... -โ”‚ โ””โ”€โ”€ style.css -โ””โ”€โ”€ templates/ - โ””โ”€โ”€ index.html # Dashboard HTML -``` - -### Adding API Endpoints - -Define new endpoints in `main.py`: - -```python -@app.get("/api/my-endpoint") -async def my_endpoint(): - """Endpoint description for OpenAPI.""" - return {"result": "data"} -``` - -### Testing - -```bash -# Interactive testing via Swagger UI -open http://localhost:8000/docs - -# Manual testing via cURL -curl http://localhost:8000/health -``` - -## Static Assets - -The dashboard serves static files from `services/dashboard/static/`: -- **JavaScript**: Charts, polling, UI interactions -- **CSS**: Dashboard styling -- **HTML**: Single-page application template - -Files are mounted at `/static` route and referenced in the HTML template. - -## Monitoring - -### Logs - -```bash -# Tail logs -docker compose logs -f dashboard - -# Expected output: -# INFO - Starting OpenDT Dashboard service... -# INFO - Config loaded from /app/config/simulation.yaml -# INFO - Kafka producer initialized -# INFO - Uvicorn running on http://0.0.0.0:8000 -``` - -### Health Endpoint - -```bash -# Check health -curl http://localhost:8000/health - -# Healthy response: -{ - "status": "healthy", - "kafka": "connected", - "config": "loaded" -} -``` - -## Related Documentation - -- [Architecture Overview](../../docs/ARCHITECTURE.md) - System design -- [Data Models](../../docs/DATA_MODELS.md) - Topology schema -- [Simulation Worker](../sim-worker/README.md) - Consumer of topology updates -- [FastAPI Documentation](https://fastapi.tiangolo.com/) - Framework reference - ---- - -For questions or contributions, see the [Contributing Guide](../../CONTRIBUTING.md). diff --git a/services/dashboard/static/js/boot.js b/services/dashboard/static/js/boot.js deleted file mode 100644 index 3c20cea..0000000 --- a/services/dashboard/static/js/boot.js +++ /dev/null @@ -1,25 +0,0 @@ -// boot.js - -setMode(localStorage.getItem(modeKey) || 'ui'); - -document.addEventListener('DOMContentLoaded', () => { - - init_graphs(); - setInterval(()=>{ drawCharts().catch(()=>{}); }, 5000); - - const energy = document.getElementById('energy_input'); - const runtime = document.getElementById('runtime_input'); - if (!energy || !runtime || typeof submitSLO !== 'function') return; - - const debounce = (fn, ms=300) => { - let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; - }; - const trigger = debounce(() => submitSLO()); - - ['change','input'].forEach(evt => { - energy.addEventListener(evt, trigger); - runtime.addEventListener(evt, trigger); - }); -}); - -setInterval(poll, 2000); \ No newline at end of file diff --git a/services/dashboard/static/js/charts.js b/services/dashboard/static/js/charts.js deleted file mode 100644 index 60da4f7..0000000 --- a/services/dashboard/static/js/charts.js +++ /dev/null @@ -1,248 +0,0 @@ -const PLOTLY_CONFIG = { - responsive: true, - displaylogo: false, - displayModeBar: false, - modeBarButtonsToRemove: ['lasso2d','select2d','autoScale2d','toggleSpikelines'] -}; - -let UIREVISION = 'persist-zoom'; - -const COLORS = { - real: '#56B4E9', - sim: '#D55E00', - grid: 'rgba(0,0,0,0.12)' -}; - -function layoutFor(_title) { - return { - paper_bgcolor: 'rgba(0,0,0,0)', - plot_bgcolor: 'rgba(0,0,0,0)', - margin: { l: 60, r: 140, t: 20, b: 80 }, - font: { color: '#000' }, - uirevision: UIREVISION, // <-- persist zoom - - legend: { - x: 1.02, y: 1, xanchor: 'left', yanchor: 'top', - bgcolor: 'rgba(0,0,0,0)', bordercolor: 'rgba(0,0,0,0.08)', - borderwidth: 0, orientation: 'v', font: { size: 13, color: '#000' } - }, - - xaxis: { - title: { text: 'Time', font: { color: '#000' } }, - tickfont: { color: '#000' }, - type: 'date', - gridcolor: COLORS.grid, - zeroline: false, - rangeslider: { visible: false } - }, - - yaxis: { - gridcolor: COLORS.grid, - zeroline: false, - tickfont: { color: '#000' }, - title: { font: { color: '#000' } } - } - }; -} - -async function fetchPowerData() { - const r = await fetch('/api/power?interval_seconds=60', { cache: 'no-store' }); - if (!r.ok) { - // If the endpoint returns 404 or 500, return empty data - console.warn('Power data fetch failed:', r.status); - return { data: [], metadata: {} }; - } - return r.json(); -} - -const PLOTS = { - cpu_usages: { - range: [0, 100], - autorange: false, - title: 'Average CPU Utilization [%]', - legend: 'Average CPU Utilization [%]', - transform: value => value * 100, - }, - power_usages: { - autorange: true, - title: 'Power [kW]', - legend: 'Power [kW]', - dual: true, // This plot has two traces - } -}; - -function mapValues(values, transform) { - if (!Array.isArray(values)) return []; - if (typeof transform !== 'function') return values; - return values.map(v => { - if (v === null || v === undefined) return null; - const numeric = Number(v); - if (!Number.isFinite(numeric)) return null; - return transform(numeric); - }); -} - -function drawPlot(plot_name, x, y, extraConfigLayout, isNew = false) { - const trace = { - x, y, - mode: 'lines', - name: (extraConfigLayout && extraConfigLayout.legend) || plot_name, - line: { color: COLORS.real, width: 2 } - }; - - // base layout - const layout = layoutFor(''); - - // always anchor plots to zero on the y-axis - layout.yaxis = { - ...layout.yaxis, - rangemode: 'tozero', - }; - - // apply y-axis config from plot-specific settings - if (extraConfigLayout) { - layout.yaxis = { - ...layout.yaxis, - ...(extraConfigLayout.range ? { range: extraConfigLayout.range } : {}), - ...(extraConfigLayout.autorange !== undefined ? { autorange: extraConfigLayout.autorange } : {}) - }; - if (extraConfigLayout.title) { - layout.yaxis.title = { - ...(layout.yaxis.title || {}), - text: '', - }; - } - } - - const el = document.getElementById(plot_name); - if (!el) { - console.warn(`drawPlot: missing
`); - return; - } - - const data = [trace]; // <-- MUST be an array - if (isNew) { - Plotly.newPlot(el, data, layout, PLOTLY_CONFIG); - } else { - Plotly.react(el, data, layout, PLOTLY_CONFIG); - } -} - -function init_graphs() { - Object.keys(PLOTS).forEach(plot_name => { - drawPlot(plot_name, [], [], PLOTS[plot_name], true); - }); -} - -function drawPowerPlot(powerData) { - const el = document.getElementById('power_usages'); - if (!el) { - console.warn('drawPowerPlot: missing
'); - return; - } - - // Extract timestamps and power values (convert W to kW) - const timestamps = powerData.map(d => d.timestamp); - const simulated = powerData.map(d => d.simulated_power / 1000); - const actual = powerData.map(d => d.actual_power / 1000); - - // Create two traces - const traceSimulated = { - x: timestamps, - y: simulated, - mode: 'lines', - name: 'Simulated', - line: { color: COLORS.sim, width: 2 } - }; - - const traceActual = { - x: timestamps, - y: actual, - mode: 'lines', - name: 'Actual', - line: { color: COLORS.real, width: 2 } - }; - - // Create layout - const layout = layoutFor(''); - layout.yaxis = { - ...layout.yaxis, - rangemode: 'tozero', - autorange: true - }; - - const data = [traceActual, traceSimulated]; - Plotly.react(el, data, layout, PLOTLY_CONFIG); -} - -async function drawCharts() { - try { - // Fetch power data from new endpoint - const response = await fetchPowerData(); - - if (response.data && response.data.length > 0) { - drawPowerPlot(response.data); - - // Update metadata display if needed - if (response.metadata) { - console.log('Power data metadata:', response.metadata); - } - } else { - console.log('No power data available yet'); - } - } catch (err) { - console.error('drawCharts error:', err); - } -} - -let powerPollingInterval = null; - -function startPowerPolling(intervalMs = 5000) { - // Clear any existing interval - if (powerPollingInterval) { - clearInterval(powerPollingInterval); - } - - // Start polling - powerPollingInterval = setInterval(() => { - drawCharts(); - }, intervalMs); - - console.log(`Power data polling started (interval: ${intervalMs}ms)`); -} - -function stopPowerPolling() { - if (powerPollingInterval) { - clearInterval(powerPollingInterval); - powerPollingInterval = null; - console.log('Power data polling stopped'); - } -} - -function startSse() { - try { - const es = new EventSource('/api/stream'); - es.onmessage = () => drawCharts(); - es.onerror = () => es.close(); - } catch (_) {} -} - -// Make sure DOM is ready and the divs exist -document.addEventListener('DOMContentLoaded', () => { - init_graphs(); - drawCharts(); - - // Start polling for power data every 5 seconds - startPowerPolling(5000); -}); - -// Cleanup on page unload -window.addEventListener('beforeunload', () => { - stopPowerPolling(); -}); - -window.init_graphs = init_graphs; -window.drawCharts = drawCharts; -window.startSse = startSse; -window.startPowerPolling = startPowerPolling; -window.stopPowerPolling = stopPowerPolling; diff --git a/services/dashboard/static/js/llm.js b/services/dashboard/static/js/llm.js deleted file mode 100644 index cc8be05..0000000 --- a/services/dashboard/static/js/llm.js +++ /dev/null @@ -1,35 +0,0 @@ -function acceptRecommendation() { - fetch('/api/accept_recommendation', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }) - .then(response => response.json()) - .then(data => { - if (data.error) { - alert('Error: ' + data.error); - } else { - alert('Success: ' + data.message); - // Optionally refresh the page or update the UI - location.reload(); - } - }) - .catch(error => { - console.error('Error:', error); - alert('Failed to accept recommendation'); - }); -} - - -// Add this to your status update function -function updateAcceptButton(state) { - const acceptBtn = document.getElementById('accept-recommendation-btn'); - if (state.best_config && state.best_config.config) { - acceptBtn.disabled = false; - acceptBtn.title = 'Accept the current LLM recommendation'; - } else { - acceptBtn.disabled = true; - acceptBtn.title = 'No recommendation available'; - } -} diff --git a/services/dashboard/static/js/polling.js b/services/dashboard/static/js/polling.js deleted file mode 100644 index c564d32..0000000 --- a/services/dashboard/static/js/polling.js +++ /dev/null @@ -1,92 +0,0 @@ -// polling.js - -async function poll(){ - try{ - const r = await fetch('/api/status', {cache:'no-store'}); - const s = await r.json(); - - applyStatus(s.status); - - const slo = s?.slo_targets || {}; - const energyField = document.getElementById('energy_input'); - const runtimeField = document.getElementById('runtime_input'); - - if (energyField && document.activeElement !== energyField) { - energyField.value = slo.energy_target ?? ''; - } - if (runtimeField && document.activeElement !== runtimeField) { - runtimeField.value = slo.runtime_target ?? ''; - } - - // right-side window pill text - const wp = document.querySelector('#windowPill'); - if (wp) { - const n = s.cycle_count ?? null; - const info = s.current_window || ''; - wp.textContent = n ? `Window ${n}` : (info || 'โ€”'); - } - - renderMetrics(s); - renderOpenDC(s.last_optimization || {}, s); - renderLLM(s.last_optimization || {}); - renderTopoTable(s.current_topology || null, "currentTopoTable", "currentTopologyJSON"); - //console.log(s); - renderBest(s.best_config || null); - }catch(e){} -} -window.poll = poll; - - -(function () { - const el = () => document.getElementById('sloCombo'); - - // Decide if SLOs are met: both energy and runtime under targets - function evalSLO(state){ - const slo = state?.slo_targets || {}; - const sim = state?.last_simulation || state?.last_optimization || {}; - const energy = Number(sim.energy_kwh ?? NaN); - const runtime = Number(sim.runtime_hours ?? NaN); - const eTarget = Number(slo.energy_target ?? NaN); - const rTarget = Number(slo.runtime_target ?? NaN); - if (!isFinite(energy) || !isFinite(runtime) || !isFinite(eTarget) || !isFinite(rTarget)) return null; - return (energy <= eTarget) && (runtime <= rTarget); - } - - // Apply visual state - function setSLOState(ok){ - const node = el(); if (!node || ok === null) return; - node.classList.toggle('ok', ok === true); - node.classList.toggle('bad', ok === false); - } - - // Poll /api/status periodically (lightweight) - async function refreshSLO(){ - try { - const res = await fetch('/api/status', {cache:'no-store'}); - const state = await res.json(); - setSLOState( evalSLO(state) ); - } catch(e){ /* ignore */ } - } - - // run on load and every 2s - window.addEventListener('DOMContentLoaded', () => { - refreshSLO(); - setInterval(refreshSLO, 2000); - }); - - ['energy_input','runtime_input'].forEach(id=>{ - const n = document.getElementById(id); - if (n) n.addEventListener('change', refreshSLO); - if (n) n.addEventListener('input', () => { /* debounce quick feedback */ }); - }); - - const _submitSLO = window.submitSLO; - if (typeof _submitSLO === 'function') { - window.submitSLO = async function(){ - const r = await _submitSLO.apply(this, arguments); - refreshSLO(); - return r; - } - } -})(); - diff --git a/services/dashboard/static/js/recommendations.js b/services/dashboard/static/js/recommendations.js deleted file mode 100644 index 89cb8bc..0000000 --- a/services/dashboard/static/js/recommendations.js +++ /dev/null @@ -1,250 +0,0 @@ -// recommendations.js - -(function(){ - const BYTES_PER_GIB = 1024 ** 3; - - const state = { - server: null, - staged: null, - editing: false, - editSnapshot: null, - dirty: false, - pending: null, - }; - - function clone(value){ - return value === undefined || value === null ? null : JSON.parse(JSON.stringify(value)); - } - - function hasHosts(topo){ - if (!topo || !Array.isArray(topo.clusters)) return false; - return topo.clusters.some(cluster => Array.isArray(cluster?.hosts) && cluster.hosts.length > 0); - } - - function escapeHtml(value){ - const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; - return String(value ?? 'โ€”').replace(/[&<>"']/g, ch => map[ch]); - } - - function inputValue(value){ - const num = Number(value); - return Number.isFinite(num) ? String(num) : ''; - } - - function memToGiB(memory){ - const size = Number(memory?.memorySize ?? NaN); - if (!Number.isFinite(size) || size <= 0) return ''; - return String(Math.round(size / BYTES_PER_GIB)); - } - - function giBToBytes(value, fallbackBytes){ - const num = Number(value); - if (!Number.isFinite(num) || num < 0) return fallbackBytes ?? 0; - return Math.round(num * BYTES_PER_GIB); - } - - function parseIntField(value, fallback){ - const num = Number(value); - if (!Number.isFinite(num) || num < 0) return Number.isFinite(fallback) ? Math.round(fallback) : 0; - return Math.round(num); - } - - function flattenHosts(topo, fn){ - if (!topo || !Array.isArray(topo.clusters)) return; - topo.clusters.forEach((cluster, cIdx) => { - const hosts = Array.isArray(cluster?.hosts) ? cluster.hosts : []; - hosts.forEach((host, hIdx) => fn(cluster, host, cIdx, hIdx)); - }); - } - - function render(){ - const body = document.querySelector('#recTable tbody'); - const table = document.getElementById('recTable'); - if (!body || !table) return; - - if (!hasHosts(state.staged)){ - body.innerHTML = 'No recommendation'; - table.classList.toggle('rec-table-editing', false); - return; - } - - const rows = []; - flattenHosts(state.staged, (cluster, host, cIdx, hIdx) => { - const clusterName = escapeHtml(cluster?.name || 'โ€”'); - const hostName = escapeHtml(host?.name || 'โ€”'); - const count = Number(host?.count ?? NaN); - const coreCount = Number(host?.cpu?.coreCount ?? NaN); - const coreSpeed = Number(host?.cpu?.coreSpeed ?? NaN); - const memoryGiB = memToGiB(host?.memory); - - if (state.editing){ - rows.push(` - - ${clusterName} - ${hostName} - - - - - - `); - } else { - const memDisplay = memoryGiB === '' ? 'โ€”' : memoryGiB; - rows.push(` - - ${clusterName} - ${hostName} - ${Number.isFinite(count) ? Math.round(count) : 'โ€”'} - ${Number.isFinite(coreCount) ? Math.round(coreCount) : 'โ€”'} - ${Number.isFinite(coreSpeed) ? Math.round(coreSpeed) : 'โ€”'} - ${memDisplay} - - `); - } - }); - - body.innerHTML = rows.join(''); - table.classList.toggle('rec-table-editing', state.editing); - } - - function updateJSON(){ - const pre = document.getElementById('recJSON'); - if (!pre) return; - const content = hasHosts(state.staged) ? state.staged : null; - const text = JSON.stringify(content, null, 2); - if (pre.textContent !== text) pre.textContent = text; - } - - function updateButtons(){ - const hasData = hasHosts(state.staged); - const accept = document.getElementById('btnAcceptRec'); - const edit = document.getElementById('btnEditRec'); - const confirm = document.getElementById('btnConfirmRec'); - const cancel = document.getElementById('btnCancelRec'); - - if (accept){ - accept.disabled = !hasData || state.editing; - } - if (edit){ - edit.disabled = !hasData || state.editing; - edit.hidden = state.editing; - } - if (confirm){ - confirm.hidden = !state.editing; - confirm.disabled = !state.editing; - } - if (cancel){ - cancel.hidden = !state.editing; - cancel.disabled = !state.editing; - } - } - - function applyPendingIfIdle(){ - if (!state.pending || state.editing || state.dirty) return; - const next = state.pending; - state.pending = null; - ingest(next); - } - - function ingest(topology){ - const incoming = clone(topology); - state.server = incoming; - if (state.editing || state.dirty){ - state.pending = incoming; - return; - } - state.staged = clone(incoming); - render(); - updateButtons(); - updateJSON(); - } - - function startEditing(){ - if (state.editing || !hasHosts(state.staged)) return; - state.editing = true; - state.editSnapshot = clone(state.staged); - render(); - updateButtons(); - } - - function commitEditing(){ - if (!state.editing) return; - const table = document.getElementById('recTable'); - if (!table) return; - - table.querySelectorAll('tbody tr').forEach(row => { - const cIdx = Number(row.dataset.clusterIndex); - const hIdx = Number(row.dataset.hostIndex); - const cluster = state.staged?.clusters?.[cIdx]; - const host = cluster && Array.isArray(cluster.hosts) ? cluster.hosts[hIdx] : null; - if (!cluster || !host) return; - - const countInput = row.querySelector('input[name="count"]'); - const coreInput = row.querySelector('input[name="coreCount"]'); - const speedInput = row.querySelector('input[name="coreSpeed"]'); - const memInput = row.querySelector('input[name="memoryGiB"]'); - - host.count = parseIntField(countInput?.value, host.count); - host.cpu = host.cpu || {}; - host.cpu.coreCount = parseIntField(coreInput?.value, host.cpu.coreCount); - host.cpu.coreSpeed = parseIntField(speedInput?.value, host.cpu.coreSpeed); - - const fallbackBytes = Number(host.memory?.memorySize ?? NaN); - const fallbackGiB = Number.isFinite(fallbackBytes) ? fallbackBytes / BYTES_PER_GIB : 0; - const memGiB = parseIntField(memInput?.value, fallbackGiB); - host.memory = host.memory || {}; - host.memory.memorySize = giBToBytes(memGiB, fallbackBytes); - }); - - state.editing = false; - state.editSnapshot = null; - state.dirty = !deepEqual(state.staged, state.server); - render(); - updateButtons(); - updateJSON(); - applyPendingIfIdle(); - } - - function cancelEditing(){ - if (!state.editing){ - applyPendingIfIdle(); - return; - } - state.staged = clone(state.editSnapshot ?? state.staged); - state.editing = false; - state.editSnapshot = null; - state.dirty = !deepEqual(state.staged, state.server); - render(); - updateButtons(); - updateJSON(); - applyPendingIfIdle(); - } - - function getTopology(){ - return hasHosts(state.staged) ? clone(state.staged) : null; - } - - function markSaved(){ - state.server = clone(state.staged); - state.dirty = false; - updateButtons(); - updateJSON(); - applyPendingIfIdle(); - } - - function deepEqual(a, b){ - return JSON.stringify(a) === JSON.stringify(b); - } - - window.recommendationEditor = { - ingest, - startEditing, - commitEditing, - cancelEditing, - getTopology, - markSaved, - isDirty: () => state.dirty, - isEditing: () => state.editing, - refresh: () => { render(); updateButtons(); updateJSON(); }, - }; -})(); diff --git a/services/dashboard/static/js/render.js b/services/dashboard/static/js/render.js deleted file mode 100644 index 43770cc..0000000 --- a/services/dashboard/static/js/render.js +++ /dev/null @@ -1,107 +0,0 @@ -// render.js - -function renderMetrics(s){ - const { tasks, frags } = parseWindowCounts(s.current_window); - $('#mCycles') && ($('#mCycles').textContent = s.cycle_count ?? 0); - $('#mTasks') && ($('#mTasks').textContent = (tasks ?? s.total_tasks ?? 0)); - $('#mFragments') && ($('#mFragments').textContent = (frags ?? s.total_fragments ?? 0)); - - const sim = s.last_simulation || {}; - $('#mEnergy') && ($('#mEnergy').textContent = fmt(sim.energy_kwh, 2)); - $('#mCPU') && ($('#mCPU').textContent = pct(sim.cpu_utilization ?? 0, 1)); - $('#mRuntime') && ($('#mRuntime').textContent = fmt(sim.runtime_hours, 1) + 'h'); - $('#mTopoUpdates') && ($('#mTopoUpdates').textContent = s.topology_updates ?? 0); - - const opt = s.last_optimization || {}; - const action = opt.action_taken || (Array.isArray(opt.action_type) ? opt.action_type[0] : null) || 'โ€”'; - $('#mLLMAction') && ($('#mLLMAction').textContent = action); - const typ = (opt.type || 'โ€”').replace('_',' '); - const llmTypeEl = $('#mLLMType'); - if (llmTypeEl) llmTypeEl.innerHTML = typ !== 'โ€”' ? `${typ}` : 'โ€”'; -} - -function renderOpenDC(sim,s){ - $('#kvEnergy') && ($('#kvEnergy').textContent = fmt(sim.energy_kwh, 2)); - $('#kvCPU') && ($('#kvCPU').textContent = pct(sim.cpu_utilization, 1)); - $('#kvRuntime') && ($('#kvRuntime').textContent = fmt(sim.runtime_hours, 1)); - $('#kvMaxPower') && ($('#kvMaxPower').textContent = fmt(sim.max_power_draw, 0)); - $('#kvSimType') && ($('#kvSimType').textContent = fmt(s.cycle_count_opt) ?? 'โ€”'); - - const pre = $('#simJSON'); - const txt = JSON.stringify(sim || null, null, 2); - if (pre && pre.textContent !== txt) pre.textContent = txt; -} - -function renderTopoTable(topo, id, jsonId){ - const body = $(`#${id} tbody`); - if (!body) return; - const rows = []; - try{ - const clusters = (topo && topo.clusters) || []; - clusters.forEach(c=>{ - (c.hosts || []).forEach(h=>{ - rows.push(` - - ${c.name || 'โ€”'} - ${h.name || 'โ€”'} - ${h.count ?? 'โ€”'} - ${h.cpu?.coreCount ?? 'โ€”'} - ${h.cpu?.coreSpeed ?? 'โ€”'} - ${Math.round((h.memory?.memorySize || 0)/1073741824) || 'โ€”'} - - `); - }); - }); - }catch(e){} - body.innerHTML = rows.length ? rows.join('') : 'No topology'; - - const preId = jsonId || `${id}JSON`; - const pre = $(`#${preId}`); - const txt = JSON.stringify(topo || null, null, 2); - if (pre && pre.textContent !== txt) pre.textContent = txt; -} - -function renderLLM(opt){ - $('#kvLLMAction') && ($('#kvLLMAction').textContent = opt.action_taken || 'โ€”'); - const reasonEl = $('#kvLLMReason'); - if (reasonEl){ - const reason = opt.reason || 'โ€”'; - reasonEl.textContent = reason; - reasonEl.classList.toggle('placeholder', reason === 'โ€”'); - } - $('#kvLLMPriority') && ($('#kvLLMPriority').textContent = opt.priority || 'โ€”'); - $('#kvLLMType') && ($('#kvLLMType').textContent = (opt.type || 'โ€”').replace('_',' ')); - - const recommended = opt.new_topology || opt.best_config?.config || null; - if (window.recommendationEditor && typeof window.recommendationEditor.ingest === 'function'){ - window.recommendationEditor.ingest(recommended); - } - - const pre = $('#llmJSON'); - const txt = JSON.stringify(opt || null, null, 2); - if (pre && pre.textContent !== txt) pre.textContent = txt; -} - -function renderBest(best){ - const badge = $('#bestScore'); - - // round the score to no decimal, but just full number, say 30, 21 - if (badge){ - if (best && best.score !== undefined && best.score !== null){ - badge.textContent = `Score: ${Number(best.score).toFixed(0)}`; - - } else { - badge.textContent = 'Score: โ€”'; - } - } - const pre = $('#bestJSON'); - const payload = best ? (best.config ? best : {config: best}) : null; - const txt = JSON.stringify(payload, null, 2); - if (pre && pre.textContent !== txt) pre.textContent = txt; -} - -window.renderMetrics = renderMetrics; -window.renderOpenDC = renderOpenDC; -window.renderLLM = renderLLM; -window.renderTopoTable = renderTopoTable; -window.renderBest = renderBest; diff --git a/services/dashboard/static/js/ui.js b/services/dashboard/static/js/ui.js deleted file mode 100644 index b8df399..0000000 --- a/services/dashboard/static/js/ui.js +++ /dev/null @@ -1,123 +0,0 @@ -// ui.js - -// UI mode / button ripple -document.addEventListener('click', e => { - const b = e.target.closest('.btn'); - if (!b) return; - const r = b.getBoundingClientRect(); - b.style.setProperty('--press-x', (e.clientX - r.left) + 'px'); - b.style.setProperty('--press-y', (e.clientY - r.top) + 'px'); -}); - -function setMode(mode){ - document.body.classList.toggle('mode-json', mode === 'json'); - $('#btnModeUI')?.classList.toggle('active', mode === 'ui'); - $('#btnModeJSON')?.classList.toggle('active', mode === 'json'); - localStorage.setItem(modeKey, mode); - if (window.drawCharts) window.drawCharts(); // <- refresh with new theme -} -window.setMode = setMode; - -function applyStatus(status){ - const up = (status||'').toLowerCase() === 'running'; - const btn = $('#toggleBtn'); - if (btn){ - btn.className = 'btn ' + (up ? 'btn-danger' : 'btn-primary'); - btn.innerHTML = up - ? ` Stop` - : ` Start`; - } - const pill = $('#statusPill'); - pill?.classList.toggle('running', up); - pill?.classList.toggle('stopped', !up); - $('#statusText') && ($('#statusText').textContent = (status || 'โ€”').toUpperCase()); -} -window.applyStatus = applyStatus; - -async function toggleSystem(){ - const status = ($('#statusText')?.textContent || '').toUpperCase(); - const endpoint = (status === 'RUNNING' || status === 'STARTING') ? '/api/stop' : '/api/start'; - try{ await fetch(endpoint, {method:'POST'}); }catch(e){} -} -window.toggleSystem = toggleSystem; - -// SLO submit handler -async function submitSLO() { - const btn = document.getElementById('submit_slo'); - if (!btn) return; - - try { - const energy = parseFloat(document.getElementById('energy_input').value); - const runtime = parseFloat(document.getElementById('runtime_input').value); - - if (isNaN(energy) || isNaN(runtime)) { - console.error('Invalid input values'); - btn.classList.add('btn-danger'); - setTimeout(() => { - btn.classList.remove('btn-danger'); - btn.classList.add('btn-primary'); - }, 1000); - return; - } - - btn.disabled = true; - btn.classList.remove('btn-primary'); - btn.classList.add('btn-ghost'); - - await fetch('/api/submit_slo', { - method: 'POST', - headers: {'Content-Type':'application/json'}, - body: JSON.stringify({ energy_target: energy, runtime_target: runtime }) - }); - - await window.poll(); - } catch(e) { - console.error('Failed to submit SLO:', e); - } finally { - if (btn) { - btn.disabled = false; - btn.classList.remove('btn-ghost'); - btn.classList.add('btn-primary'); - } - } -} -window.submitSLO = submitSLO; - -// Recommendation Action -async function acceptRecommendation() { - const btn = $('#btnAcceptRec'); - if (!btn) return; - - try { - btn.disabled = true; - const topology = window.recommendationEditor?.getTopology() || null; - const payload = topology ? { topology } : {}; - - const response = await fetch('/api/accept_recommendation', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - if (!response.ok) { - let detail = ''; - try { - const err = await response.json(); - detail = err?.error ? `: ${err.error}` : ''; - } catch(_) { /* ignore JSON parse issues */ } - throw new Error(`HTTP ${response.status}${detail}`); - } - - if (window.recommendationEditor && typeof window.recommendationEditor.markSaved === 'function') { - window.recommendationEditor.markSaved(); - } - - await window.poll(); - } catch(e) { - console.error('Failed to accept recommendation:', e); - } finally { - btn.disabled = false; - } -} -window.acceptRecommendation = acceptRecommendation; - diff --git a/services/dashboard/static/js/utils.js b/services/dashboard/static/js/utils.js deleted file mode 100644 index 0b0a8eb..0000000 --- a/services/dashboard/static/js/utils.js +++ /dev/null @@ -1,41 +0,0 @@ -// utils.js -const $ = s => document.querySelector(s); -const modeKey = 'opendt-view-mode'; - -// ---- helpers ------------------------------------------------- -function fmt(n, d = 1){ return (n===null||n===undefined||isNaN(n)) ? 'โ€”' : Number(n).toFixed(d); } -function pct(n, d = 1){ return (n===null||n===undefined||isNaN(n)) ? 'โ€”' : (Number(n)*100).toFixed(d) + '%'; } - -// Parse "Window 1: 121 tasks, 1155 fragments" -function parseWindowCounts(info){ - const m = /:\s*([\d,]+)\s*tasks?\s*,\s*([\d,]+)\s*fragments?/i.exec(info || ""); - if (!m) return { tasks: null, frags: null }; - const toInt = s => parseInt(String(s).replace(/,/g,''), 10); - return { tasks: toInt(m[1]), frags: toInt(m[2]) }; -} - -// Decide if x looks like real timestamps (very loose check) -function looksLikeDates(arr) { - if (!arr || !arr.length) return false; - const v = arr[0]; - return typeof v === 'string' && /\d{4}-\d{2}-\d{2}.*\d{2}:\d{2}/.test(v); -} - -// ----- time normalization helpers (fix 1970) ----- -function _isBadEpoch(v){ - const d = new Date(v); - return !isFinite(d) || d.getFullYear() < 2000; -} -function _genTimeline(count, stepMs = 5*60*1000, anchorMs = Date.now()){ - const start = anchorMs - (count - 1) * stepMs; - const arr = new Array(count); - for (let i = 0; i < count; i++) arr[i] = new Date(start + i*stepMs).toISOString(); - return arr; -} -function _normalizeX(x, yLen, stepMs = 5*60*1000, anchorMs = Date.now()){ - if (Array.isArray(x) && x.length === yLen && x.length > 0) { - const first = x.find(v => v != null); - if (first && !_isBadEpoch(first)) return x; // already real timestamps - } - return _genTimeline(yLen, stepMs, anchorMs); // fabricate sane "now" timeline -} diff --git a/services/dashboard/static/style.css b/services/dashboard/static/style.css deleted file mode 100644 index 2d159ad..0000000 --- a/services/dashboard/static/style.css +++ /dev/null @@ -1,970 +0,0 @@ -/************************************************************ - OpenDT โ€” Liquid Glass UI (Apple macOS / iOS-style, v3) -************************************************************/ - -/* 0) Fonts */ -@font-face{font-family:"SF Pro Text";src:local("SF Pro Text"),local("-apple-system");font-display:swap} -:root{ - --font: Inter,"SF Pro Text",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; - - /* Apple neutrals (light) */ - --ink:#0b1220; /* text */ - --ink-muted:#6b7280; - --bg:#f5f5f7; /* Apple site gray */ - --bg-1:#fafafa; - --bg-2:#eef1f5; - - /* Apple accents (macOS/iOS) */ - --blue:#007aff; /* iOS primary */ - --blue-2:#0a84ff; /* macOS primary */ - --green:#34c759; - --red:#ff3b30; - --yellow:#ffd60a; - --indigo:#5e5ce6; - --teal:#64d2ff; - - /* Glass */ - --surface:rgba(255,255,255,.72); - --surface-strong:rgba(255,255,255,.82); - --surface-weak:rgba(255,255,255,.52); - --glass-border:rgba(255,255,255,.55); - --hairline:rgba(0,0,0,.08); - --hairline-strong:rgba(0,0,0,.14); - - --shadow-1:0 6px 18px rgba(16,18,27,.08); - --shadow-2:0 12px 30px rgba(16,18,27,.14); - --shadow-3:0 24px 60px rgba(16,18,27,.18); - - --h1:24px; --h2:18px; --sub:13px; - --ease-spring:cubic-bezier(.2,.8,.2,1); - --dur-quick:160ms; --dur-mid:260ms; --dur-slow:420ms; - - --row-bg:rgba(255,255,255,.6); - --code-bg:rgba(17,24,39,.78); --code-ink:#e5e7eb; - - --icon:20px; - --dock-size:64px; -} - -/* dark mode */ -@media (prefers-color-scheme: dark){ - :root{ - --ink:#e7eaf0; --ink-muted:#9aa3b2; - --bg:#0d0f13; --bg-1:#0d0f13; --bg-2:#131722; - - --surface:rgba(28,33,43,.58); - --surface-strong:rgba(28,33,43,.7); - --surface-weak:rgba(28,33,43,.38); - --glass-border:rgba(255,255,255,.08); - --hairline:rgba(255,255,255,.10); - --hairline-strong:rgba(255,255,255,.16); - - --row-bg:rgba(255,255,255,.07); - --code-bg:rgba(0,0,0,.65); --code-ink:#e5e7eb; - } -} - -/* 1) Base layout & background */ -*{box-sizing:border-box} -html,body{height:100%} -body{ - margin:0; font-family:var(--font); color:var(--ink); - background: - radial-gradient(1100px 700px at 10% -10%, #ffffff 0%, transparent 60%), - radial-gradient(1000px 600px at 90% 0%, #e8ecf6 0%, transparent 60%), - linear-gradient(180deg, var(--bg-1), var(--bg-2)); - background-attachment:fixed; -} -.container{max-width:1440px;margin:0 auto;padding:28px} - -/* 2) Liquid glass primitives */ -.glass{ - background:var(--surface); - -webkit-backdrop-filter:saturate(180%) blur(22px); - backdrop-filter:saturate(180%) blur(22px); - border:1px solid var(--glass-border); - box-shadow:var(--shadow-1); - position:relative; overflow:hidden; -} - -.slo-inputs { - padding: 20px; - margin-top: 20px; - border-radius: 8px; -} - -.input-group { - margin-bottom: 16px; -} - -.input-group label { - display: block; - margin-bottom: 8px; - color: var(--ink-muted); - font-size: 14px; -} - -.glass-input { - width: 100%; - padding: 8px 12px; - border-radius: 6px; - border: 1px solid var(--glass-border); - background: var(--surface-weak); - color: var(--ink); - font-size: 14px; - transition: all var(--dur-quick) var(--ease-spring); -} - -.glass-input:focus { - outline: none; - border-color: var(--blue); - background: var(--surface-strong); - box-shadow: 0 0 0 2px rgba(0,122,255,0.1); -} - -.glass-btn { - margin-top: 8px; - width: 100%; - justify-content: center; -} -.glass::before,.glass::after{content:"";position:absolute;inset:0;pointer-events:none} -.glass::before{ - background:linear-gradient(to bottom, rgba(255,255,255,.42), transparent 60%); - mix-blend-mode:screen; opacity:.55; -} -.glass::after{ - background:radial-gradient(120% 100% at 50% 120%, rgba(0,0,0,.14), transparent 55%); - opacity:.2; -} -.lift{transition:transform var(--dur-quick) var(--ease-spring), box-shadow var(--dur-quick) var(--ease-spring), filter var(--dur-quick)} -.lift:hover{transform:translateY(-2px); box-shadow:var(--shadow-2)} -.lift:active{transform:translateY(0) scale(.992); box-shadow:var(--shadow-1)} -.sheen{position:relative;overflow:hidden} -.sheen::after{ - content:"";position:absolute;inset:-40%; - background: - radial-gradient(50% 60% at 0% 0%, rgba(255,255,255,.16), transparent 55%), - radial-gradient(40% 50% at 100% 20%, rgba(255,255,255,.12), transparent 60%), - radial-gradient(60% 60% at 30% 110%, rgba(255,255,255,.10), transparent 70%); - transition:transform 1100ms var(--ease-spring); mix-blend-mode:screen; -} -.sheen:hover::after{transform:translate3d(-4%,-2%,0)} -.sheen:active::after{transform:translate3d(-1%,-1%,0) scale(.995)} - -/* 3) Header */ -.header{ - border-radius:20px; color:#fff; padding:24px 22px; - background:linear-gradient(180deg, rgba(22,24,30,.88), rgba(22,24,30,.74)); - border:1px solid rgba(255,255,255,.08); box-shadow:0 24px 48px rgba(0,0,0,.35); -} -.header h1{margin:0 0 6px; font-size:var(--h1); letter-spacing:.2px; display:flex; align-items:center; gap:8px} -.header .icon{filter:drop-shadow(0 1px 0 rgba(255,255,255,.25)) invert(1) opacity(.9)} -.header .sub{opacity:.92; font-size:var(--sub)} - -/* 4) Buttons (Apple accent) */ -.btn{ - --press-x:50%; --press-y:50%; - position:relative; overflow:hidden; border:0; border-radius:14px; - padding:12px 16px; font-weight:800; cursor:pointer; color:#fff; - box-shadow:0 1px 0 rgba(255,255,255,.18) inset, var(--shadow-1); - -webkit-backdrop-filter:saturate(180%) blur(10px); - backdrop-filter:saturate(180%) blur(10px); - transition:transform var(--dur-quick) var(--ease-spring), box-shadow var(--dur-quick), filter var(--dur-quick); -} -.btn:hover{transform:translateY(-1px); box-shadow:var(--shadow-2)} -.btn:active{transform:translateY(0) scale(.985); filter:brightness(.96)} -.btn::after{ - content:""; position:absolute; inset:0; pointer-events:none; - background:radial-gradient(140px 140px at var(--press-x) var(--press-y), rgba(255,255,255,.38), transparent 60%); - opacity:0; transform:scale(.8); - transition:opacity 380ms var(--ease-spring), transform 380ms var(--ease-spring); -} -.btn:active::after{opacity:.75; transform:scale(1)} -.btn:hover::after{opacity:.22} -.btn-primary{background:linear-gradient(180deg, color-mix(in srgb, var(--blue) 90%, white 10%), var(--blue-2))} -.btn-danger{background:linear-gradient(180deg, color-mix(in srgb, var(--red) 90%, white 10%), var(--red))} -.btn-ghost{background:var(--surface-weak); color:var(--ink); border:1px solid var(--hairline)} - -.badge{ - display:inline-flex; align-items:center; gap:6px; - background:color-mix(in srgb, var(--blue) 12%, white 88%); - color:#0b3d99; border:1px solid color-mix(in srgb, var(--blue) 20%, white 80%); - padding:6px 10px; border-radius:999px; font-weight:700; font-size:12px; -} - -/* 5) Icons via masks */ -.icon{width:var(--icon); height:var(--icon); display:inline-block; background:currentColor; -webkit-mask:var(--icon-url) center/contain no-repeat; mask:var(--icon-url) center/contain no-repeat} -:root{ - --sym-play:url("data:image/svg+xml;utf8,"); - --sym-stop:url('data:image/svg+xml;utf8,'); - --sym-gauge:url('data:image/svg+xml;utf8,'); - --sym-bolt:url('data:image/svg+xml;utf8,'); - --sym-cpu:url('data:image/svg+xml;utf8,'); - --sym-clock:url('data:image/svg+xml;utf8,'); - --sym-topo:url('data:image/svg+xml;utf8,'); - --sym-wand:url('data:image/svg+xml;utf8,'); - --sym-chart:url('data:image/svg+xml;utf8,'); - --sym-check:url('data:image/svg+xml;utf8,'); - --sym-edit:url('data:image/svg+xml;utf8,'); -} - -/* 6) Status + segmented control */ -.status{ - display:flex; align-items:center; gap:8px; background:var(--surface-weak); - padding:10px 14px; border-radius:999px; border:1px solid var(--glass-border); - -webkit-backdrop-filter:blur(14px) saturate(160%); backdrop-filter:blur(14px) saturate(160%); - box-shadow:var(--shadow-1) -} -.status .dot{width:10px;height:10px;border-radius:999px;background:#9ca3af;box-shadow:0 0 0 3px rgba(0,0,0,.04) inset} -.status.running .dot{background:var(--green)} -.status.stopped .dot{background:var(--red)} -.mode-switch{margin-left:12px} - -.seg{display:inline-flex; gap:2px; padding:4px; border-radius:9999px; background:var(--surface-weak); border:1px solid var(--glass-border); -webkit-backdrop-filter:blur(14px) saturate(160%); backdrop-filter:blur(14px) saturate(160%); box-shadow:var(--shadow-1)} -.seg button{border:0;background:transparent;padding:8px 14px;border-radius:9999px;font-weight:700;cursor:pointer;color:var(--ink);transition:background var(--dur-quick), transform var(--dur-quick)} -.seg button:hover{transform:translateY(-1px)} -.seg button.active{background:var(--surface-strong); border:1px solid var(--hairline); box-shadow:inset 0 1px 0 rgba(255,255,255,.35)} - -/* 7) Metrics & cards */ -.metrics{display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:18px; margin:20px 0; grid-template-columns: repeat(6, 1fr)} -.metric{border-radius:18px; padding:18px; position:relative} -.metric h3{margin:0 0 6px; font-size:12px; letter-spacing:.08em; color:var(--ink-muted); display:flex; align-items:center; gap:6px} -.metric .value{font-size:28px; font-weight:900} -.metric:hover::before{content:""; position:absolute; inset:0; pointer-events:none; background:linear-gradient(115deg, transparent 40%, rgba(255,255,255,.18) 50%, transparent 60%); transform:translateX(-120%); animation:sweep 900ms var(--ease-spring)} -.metric:hover{box-shadow:var(--shadow-2), 0 0 0 4px color-mix(in srgb, var(--blue) 16%, transparent)} -@keyframes sweep{to{transform:translateX(120%)}} - -.section-grid{display:grid; grid-template-columns:1fr 1fr; gap:18px; margin-bottom:20px} -.card{border-radius:18px; padding:18px} -.card h3{margin:0 0 12px; font-size:15px} - -/* KV rows */ -.kv{display:grid; grid-template-columns:1fr 1fr; gap:12px} -.kv>div{display:flex; flex-direction:column; justify-content:space-between; padding:12px 14px; border-radius:12px; background:var(--surface-weak); border:1px solid var(--hairline); transition:transform var(--dur-quick), box-shadow var(--dur-quick)} -.kv>div:hover{transform:translateY(-1px); box-shadow:var(--shadow-1)} -.kv span{color:var(--ink-muted); font-size:12px; margin-bottom:8px} -.kv strong{font-weight:800} - -.slo-row { - display: flex; - align-items: center; - gap: 12px; -} - -.slo-field { - flex: 1; - min-width: 140px; - height: 32px; - background: rgba(255,255,255,0.1); - border: 1px solid rgba(255,255,255,0.2); - border-radius: 6px; - padding: 4px 8px; - color: var(--ink); - font-size: 13px; -} - -.slo-field:focus { - outline: none; - border-color: var(--blue); - background: rgba(255,255,255,0.15); -} - -.slo-field::placeholder { - color: rgba(255,255,255,0.5); -} - -/* SLO inputs compact grid */ -.slo-inputs { padding: 8px 12px; } -.slo-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: center; } -.slo-grid .input-group { margin: 0; padding: 2px 4px; } -.slo-grid label { font-size: 11px; color: var(--ink-muted); margin-bottom: 4px; } -.slo-grid .glass-input { padding: 6px 8px; border-radius: 8px; font-size: 13px; } -.slo-actions { grid-column: 1 / -1; display:flex; margin-top: 8px; } -.slo-actions .glass-btn { width: auto; padding: 8px 12px; margin-left: auto; border-radius: 10px; font-size: 13px; } - -@media (max-width: 720px) { - .slo-grid { grid-template-columns: 1fr; gap: 12px; } - .slo-actions { justify-content: stretch; } - .slo-actions .glass-btn { width:100%; margin-left:0 } -} - -/* Pill-style inputs (compact, rounded with right sigil) */ -.pill-input{ display:flex; align-items:center; gap:8px; padding:8px 12px 8px 14px; background:rgba(255,255,255,0.98); border-radius:20px; border:1px solid rgba(0,0,0,0.06); box-shadow:0 4px 12px rgba(16,18,27,0.04); min-height:36px } -.pill-field{ flex:1; border:0; background:transparent; padding:4px 6px; font-size:13px; color:var(--ink-muted); outline:none; line-height:1 } -.pill-field::placeholder{ color:var(--ink-muted); opacity:0.7 } -.pill-sigil{ display:inline-flex; align-items:center; justify-content:center; width:16px; height:4px; border-radius:4px; background:rgba(17,20,25,0.9); color:#fff; font-weight:700; border:0; font-size:9px } - -/* subtle inner top highlight to match screenshot */ -.pill-input::before{ content:''; position:absolute; pointer-events:none; inset:auto auto 100% 0; height:1px } - -/* make each pill visually separated on wide screens with subtle divider */ -.slo-grid > .input-group { background: transparent } -.slo-grid > .input-group .pill-input { width: 100% } - -.visually-hidden{ position:absolute!important; height:1px; width:1px; overflow:hidden; clip:rect(1px,1px,1px,1px); white-space:nowrap } - -.slo-submit { - margin-top: 16px; - padding: 0 14px 14px; - display: flex; - justify-content: flex-end; -} - -.slo-submit .btn { - min-width: 120px; -} - -/* panel */ -.panel{margin-top:12px; border-radius:12px; border:1px solid var(--hairline); background:var(--surface-weak)} -.panel-title{padding:10px 12px; border-bottom:1px solid var(--hairline); font-weight:700; background:linear-gradient(180deg, rgba(255,255,255,.25), transparent)} - -/* Recommendation action buttons */ -.rec-actions { - display: flex; - margin-top: 1rem; - padding: 1rem; - justify-content: center; -} - -.btn-success { - background: linear-gradient(180deg, - color-mix(in srgb, var(--green) 70%, white 30%), - color-mix(in srgb, var(--green) 90%, white 10%) - ); - border: 1px solid rgba(255,255,255,.35); - box-shadow: - 0 1px 0 rgba(255,255,255,.5) inset, - 0 8px 20px rgba(52,199,89,.15); - backdrop-filter: blur(8px); -} - -.btn-success:hover { - background: linear-gradient(180deg, - color-mix(in srgb, var(--green) 65%, white 35%), - color-mix(in srgb, var(--green) 85%, white 15%) - ); - transform: translateY(-1px); - box-shadow: - 0 1px 0 rgba(255,255,255,.5) inset, - 0 12px 28px rgba(52,199,89,.2); -} - -.btn-danger { - background: linear-gradient(180deg, - color-mix(in srgb, var(--red) 70%, white 30%), - color-mix(in srgb, var(--red) 90%, white 10%) - ); - border: 1px solid rgba(255,255,255,.35); - box-shadow: - 0 1px 0 rgba(255,255,255,.5) inset, - 0 8px 20px rgba(255,59,48,.15); - backdrop-filter: blur(8px); -} - -.btn-danger:hover { - background: linear-gradient(180deg, - color-mix(in srgb, var(--red) 65%, white 35%), - color-mix(in srgb, var(--red) 85%, white 15%) - ); - transform: translateY(-1px); - box-shadow: - 0 1px 0 rgba(255,255,255,.5) inset, - 0 12px 28px rgba(255,59,48,.2); -} - -/* 8) Table */ -table{width:100%; border-collapse:separate; border-spacing:0 10px} -thead th{font-size:12px; color:var(--ink-muted); text-align:left; padding:6px 10px} -tbody td{background:var(--row-bg); padding:12px 12px; border-top:1px solid var(--hairline); border-bottom:1px solid var(--hairline); transition:background var(--dur-quick)} -tbody tr td:first-child{border-top-left-radius:12px; border-bottom-left-radius:12px} -tbody tr td:last-child{border-top-right-radius:12px; border-bottom-right-radius:12px} -tbody tr:hover td{background:color-mix(in srgb, var(--row-bg) 85%, white 15%)} - -.rec-actions{display:flex; gap:10px; margin:16px 0 12px; flex-wrap:wrap; align-items:center} -.rec-actions .btn{flex:0 0 auto} -.rec-edit-input{width:100%; padding:8px 10px; border-radius:10px; border:1px solid var(--hairline); background:var(--surface-strong); color:inherit; font:inherit} -.rec-edit-input:focus{border-color:color-mix(in srgb, var(--blue) 65%, white 35%); box-shadow:0 0 0 3px color-mix(in srgb, var(--blue) 30%, transparent 70%)} -.llm-reason{margin:14px 0 6px; color:var(--ink-muted); line-height:1.45} -.llm-reason.placeholder{opacity:.7; font-style:italic} -.rec-table-editing tbody td{background:color-mix(in srgb, var(--row-bg) 80%, white 20%)} - -/* 9) JSON mode */ -.json-only{display:none; white-space:pre-wrap; background:var(--code-bg); color:var(--code-ink); border-radius:12px; padding:14px; border:1px solid rgba(255,255,255,.08); box-shadow:inset 0 1px 0 rgba(255,255,255,.05)} -.ui-only{display:block} -.mode-json .ui-only{display:none} -.mode-json .json-only{display:block} - -/* 10) Misc */ -:focus-visible{outline:2px solid color-mix(in srgb, var(--blue) 70%, white 30%); outline-offset:2px; border-radius:12px} -*{scrollbar-width:thin; scrollbar-color:color-mix(in srgb, var(--ink) 35%, transparent) transparent} -*::-webkit-scrollbar{height:10px; width:10px} -*::-webkit-scrollbar-track{background:transparent} -*::-webkit-scrollbar-thumb{background:color-mix(in srgb, var(--ink) 25%, transparent); border-radius:999px; border:2px solid transparent; background-clip:padding-box} -*::-webkit-scrollbar-thumb:hover{background:color-mix(in srgb, var(--ink) 35%, transparent)} - -.container{padding-top:32px} .header{margin-bottom:24px} .controls{margin-bottom:22px} .metrics{margin-bottom:22px} .section-grid{margin-bottom:22px} -.compact .metric .value{font-size:24px} .compact .kv{grid-template-columns:1fr} - -.controls, .metrics .metric, .card, .status, .seg{background:var(--surface); -webkit-backdrop-filter:saturate(180%) blur(22px); backdrop-filter:saturate(180%) blur(22px); border:1px solid var(--glass-border); box-shadow:var(--shadow-1)} -.controls{background:var(--surface-strong)} -.metrics .metric:hover, .card:hover{box-shadow:var(--shadow-2)} - -/* --- Softer radii + lighter borders for the status area (and siblings) --- */ -:root{ - --r-sm: 14px; - --r-md: 18px; - --r-lg: 22px; - --r-xl: 26px; -} - - -/* Status strip (the โ€œStatus: RUNNINGโ€ bar) */ -.status{ - border-radius: var(--r-xl) !important; - padding: 12px 18px !important; - border-color: rgba(255,255,255,.40) !important; - box-shadow: - 0 1px 0 rgba(255,255,255,.25) inset, - 0 10px 24px rgba(16,18,27,.10) !important; -} - -/* The white control container bar beneath the header */ -.controls{ - border-radius: var(--r-xl) !important; - box-shadow: - 0 1px 0 rgba(255,255,255,.25) inset, - 0 14px 30px rgba(16,18,27,.10) !important; -} - -/* Segmented control + its buttons (UI / JSON) */ -.seg{ - border-radius: 9999px !important; - padding: 5px !important; -} -.seg button{ - border-radius: 9999px !important; -} -.seg button.active{ - box-shadow: inset 0 1px 0 rgba(255,255,255,.35), 0 6px 14px rgba(16,18,27,.06) !important; -} - -/* Cards and metric tiles โ€” slightly rounder to match */ -.card, -.metrics .metric{ - border-radius: var(--r-lg) !important; -} - -/* KV rows inside cards */ -.kv > div{ - border-radius: var(--r-md) !important; -} - -/* Make the little โ€œdotโ€ in the status pill a hair softer */ -.status .dot{ - box-shadow: 0 0 0 4px rgba(0,0,0,.03) inset; -} - - -/* ========================================= - @Large โ€œenergyโ€ header (teal โ†’ cyan โ†’ blue) - ========================================= */ -.header.energy-header{ - /* keep your rounded Apple glass shape but recolor */ - color:#fff; - border-radius:28px; /* a bit rounder per your feedback */ - background: - /* dark glass base for depth */ - linear-gradient(180deg, rgba(10,14,22,.92), rgba(10,14,22,.82)), - /* soft teal glow left */ - radial-gradient(120% 180% at -10% -40%, rgba(0,194,168,.35) 0%, transparent 60%), - /* cyan glow right */ - radial-gradient(120% 180% at 110% -30%, rgba(0,181,226,.33) 0%, transparent 60%), - /* subtle color ribbon across */ - linear-gradient(90deg, #00c2a8 0%, #00b5e2 48%, #0066ff 100%); - background-blend-mode: overlay, normal, normal, color; - border: 1px solid rgba(255,255,255,.10); - box-shadow: - 0 22px 55px rgba(0, 40, 80, .28), - inset 0 1px 0 rgba(255,255,255,.18); -} -.header.energy-header::before{ - /* refined highlight */ - content:""; position:absolute; inset:0; pointer-events:none; - background: linear-gradient(to bottom, rgba(255,255,255,.14), transparent 55%); - mix-blend-mode: screen; opacity:.9; -} - -/* ========================================= - Controls in one single row (no wrapping) - ========================================= */ -.controls .row{ - display:flex; - align-items:center; - gap:14px; -} -.controls .mode-switch{ margin-left:12px; } /* pushes UI/JSON (and your theme switcher if any) to the right */ - -/* keep the big rounded, non-pointy feel */ -.controls, -.status, -.seg { border-radius: 22px; } -.btn { border-radius: 16px; } - -/* ensure the status pill and the segmented control align nicely */ -.status{ padding:10px 14px; height:44px; margin: 20px } -.seg button{ height:40px; display:inline-flex; align-items:center; } - -/* optional: keep Start button visually dominant without growing too tall */ -#toggleBtn{ height:48px; padding: 0 16px; display:inline-flex; align-items:center; gap:8px; } - -.controls .row { padding-inline: 12px; } /* space from container edges */ -.controls .row > * { margin-inline: 6px; } /* Start / Status / UIโ€“JSON */ - -/* Right-side window chip in controls bar */ -.window-pill{ - margin-left:auto; /* <-- key */ - background: var(--surface-weak); - border: 1px solid var(--glass-border); - -webkit-backdrop-filter: blur(14px) saturate(160%); - backdrop-filter: blur(14px) saturate(160%); - box-shadow: var(--shadow-1); - padding: 10px 14px; - border-radius: 999px; - font-weight: 700; - white-space: nowrap; - margin-right: 15px; -} -@media (max-width: 720px){ - .window-pill{ display:none; } /* optional: hide on small screens */ -} - - - -.kv + .kv { - margin-top: 2rem; /* Adjust as needed for more/less space */ -} - -/* Or, if you want to target just the input group */ -.input-group { - margin-top: 1.5rem; -} - -/* --- CHARTS (clean, responsive) --- */ - -/* one-column stack, same spacing as other sections */ -.section-stack{ - display:flex; - flex-direction:column; - gap:18px; - margin-bottom:22px; - width:100%; -} - -/* chart cards fill the width */ -.section-stack .card{ - width:100%; - border-radius: var(--r-lg); - padding:18px; -} - -/* Plot container sizing */ -.chart-wrap{ width:100%; } -.chart{ - width:100%; - min-height:360px; -} - -/* Force Plotly internals to be fluid */ -.plotly, -.plot-container, -.plotly-graph-div{ - width:100% !important; - height:100% !important; -} - -/* Optional: nicer modebar look */ -.plotly .modebar { background: transparent; } -.plotly .modebar-btn svg { fill: #cbd5e1 !important; } -.plotly .modebar-btn:hover svg, -.plotly .modebar-btn.active svg { fill: #22d3ee !important; } -.plotly .modebar-btn:hover { - background: rgba(255,255,255,0.08) !important; - border-radius: 8px; -} - -.chart-toolbar{ - display:flex; gap:.5rem; align-items:center; margin-bottom:.5rem; -} -.chart-toolbar .btn-ghost{ - padding:.4rem .8rem; border:1px solid var(--glass-border); - background:transparent; border-radius:.6rem; cursor:pointer; -} -.chart-toolbar .hint{opacity:.7; font-size:.85rem; margin-left:auto;} - -/* Plotly toolbar dark theme */ -:root{ - /* tweak these to taste */ - --plotly-modebar-bg: rgba(255,255,255,0.08); - --plotly-icon: #e5e7eb; /* slate-200 */ - --plotly-icon-hover: #f3f4f6; /* gray-100 */ - --plotly-icon-active: #22d3ee; /* cyan/mint highlight */ - --plotly-rs-bg: rgba(255,255,255,0.06); -} - -.js-plotly-plot .modebar{ - background: var(--plotly-modebar-bg) !important; - backdrop-filter: blur(2px); - border-radius: 10px !important; - box-shadow: 0 6px 16px rgba(0,0,0,0.35); - padding: 2px 4px; -} - -.js-plotly-plot .modebar-btn{ - background: transparent !important; - border-radius: 8px !important; - margin: 0 2px !important; -} - -.js-plotly-plot .modebar-btn:hover{ - background: rgba(255,255,255,0.18) !important; -} - -/* recolor the glyphs */ -.js-plotly-plot .modebar-btn svg path, -.js-plotly-plot .modebar-btn svg polygon{ - fill: var(--plotly-icon) !important; -} -.js-plotly-plot .modebar-btn:hover svg path, -.js-plotly-plot .modebar-btn:hover svg polygon{ - fill: var(--plotly-icon-hover) !important; -} -.js-plotly-plot .modebar-btn.active svg path, -.js-plotly-plot .modebar-btn.active svg polygon{ - fill: var(--plotly-icon-active) !important; -} - -/* rangeslider background */ -.js-plotly-plot .rangeslider-bg rect{ - fill: var(--plotly-rs-bg) !important; -} - - - -/* Minimal, non-intrusive modebar styling */ -.js-plotly-plot .modebar { background: transparent; } -.js-plotly-plot .modebar-btn svg path, -.js-plotly-plot .modebar-btn svg polygon { fill: #cbd5e1 !important; } -.js-plotly-plot .modebar-btn:hover { background: rgba(255,255,255,0.08) !important; border-radius: 8px; } - -/* ---- Plotly modebar: kill light blue hover/active ---- */ -.js-plotly-plot .modebar { background: transparent !important; } - -/* group container background */ -.js-plotly-plot .modebar-group { - background: rgba(0,0,0,0.28) !important; /* subtle dark */ - border-radius: 8px !important; -} - -/* button states */ -.js-plotly-plot .modebar-btn { - background: transparent !important; - box-shadow: none !important; -} -.js-plotly-plot .modebar-btn:hover, -.js-plotly-plot .modebar-btn:focus, -.js-plotly-plot .modebar-btn.active { - background: rgba(255,255,255,0.08) !important; /* NOT blue */ - box-shadow: none !important; - outline: none !important; -} - -/* icon color (SVG paths & polygons) */ -.js-plotly-plot .modebar-btn svg path, -.js-plotly-plot .modebar-btn svg polygon { - fill: #d1d5db !important; /* light gray icon */ - stroke: none !important; -} -.js-plotly-plot .modebar-btn:hover svg path, -.js-plotly-plot .modebar-btn:hover svg polygon, -.js-plotly-plot .modebar-btn.active svg path, -.js-plotly-plot .modebar-btn.active svg polygon { - fill: #f8fafc !important; /* a bit brighter on hover */ -} - -/* remove any browser focus ring */ -.js-plotly-plot .modebar-btn:focus-visible { outline: none !important; } - -.plotly-notifier { display: none !important; } - -/* --- SLO pill: glass gradient + soft shadow to match controls --- */ -.controls .slo-combo{ - display:flex; align-items:center; gap:14px; padding:10px 14px; - border-radius:999px; - background: - linear-gradient(180deg, color-mix(in srgb, #fff 92%, transparent) 0%, transparent 100%), - var(--surface-weak); - border:1px solid var(--glass-border); - -webkit-backdrop-filter: blur(22px) saturate(180%); - backdrop-filter: blur(22px) saturate(180%); - box-shadow: - 0 1px 0 rgba(255,255,255,.45) inset, - 0 10px 24px rgba(16,18,27,.10); -} - -/* inline label + value styling */ -.slo-item{ display:flex; align-items:center; gap:1px; } -.slo-item label{ - font-size:12px; font-weight:800; letter-spacing:.02em; - color: var(--ink-muted); -} - -/* value chip look for inputs (like other counters) */ -.slo-inline-field{ - width:84px; padding:6px 12px; border:0; border-radius:999px; - background: rgba(255,255,255,.75); - color: var(--ink); font-size:13px; font-weight:800; outline:none; - box-shadow: 0 1px 0 rgba(255,255,255,.45) inset; - transition: background var(--dur-quick) var(--ease-spring), box-shadow var(--dur-quick); -} -.slo-inline-field::placeholder{ color: color-mix(in srgb, var(--ink-muted) 80%, transparent); } - -/* focus = subtle blue glow to match primary */ -.slo-inline-field:focus{ - background: var(--surface-strong); - box-shadow: - 0 0 0 2px color-mix(in srgb, var(--blue) 22%, transparent), - 0 1px 0 rgba(255,255,255,.5) inset; -} - -/* divider tone aligned with other pills */ -.slo-divider{ - width:1px; height:22px; - background: color-mix(in srgb, var(--glass-border) 80%, #fff 20%); - border-radius:1px; -} - -/* hover polish consistent with controls */ -.controls .slo-combo:hover{ - box-shadow: - 0 1px 0 rgba(255,255,255,.45) inset, - 0 14px 30px rgba(16,18,27,.14); -} - -/* dark mode tune */ -@media (prefers-color-scheme: dark){ - .controls .slo-combo{ - background: - linear-gradient(180deg, rgba(255,255,255,.06), transparent), - var(--surface-weak); - border-color: var(--glass-border); - } - .slo-inline-field{ - background: rgba(255,255,255,.10); - color: var(--ink); - box-shadow: 0 1px 0 rgba(255,255,255,.06) inset; - } - .slo-inline-field:focus{ - background: rgba(255,255,255,.16); - box-shadow: - 0 0 0 2px color-mix(in srgb, var(--blue) 30%, transparent), - 0 1px 0 rgba(255,255,255,.08) inset; - } -} - - - - -/* states */ -.slo-combo.ok{ - border-color: color-mix(in srgb, var(--green) 50%, #fff 50%); - box-shadow: 0 1px 0 rgba(255,255,255,.06) inset, 0 12px 26px rgba(0,0,0,.22), - 0 0 0 3px color-mix(in srgb, var(--green) 18%, transparent); -} -.slo-combo.bad{ - border-color: color-mix(in srgb, var(--red) 50%, #fff 50%); - box-shadow: 0 1px 0 rgba(255,255,255,.06) inset, 0 12px 26px rgba(0,0,0,.22), - 0 0 0 3px color-mix(in srgb, var(--red) 18%, transparent); -} - -/* the tiny dot at the start reflects state */ -.slo-combo::before{ content:""; width:6px; height:6px; border-radius:999px; margin-right:6px; background:#aaa; } -.slo-combo.ok::before { background: var(--green); box-shadow: 0 0 0 3px rgba(52,199,89,.18); } -.slo-combo.bad::before { background: var(--red); box-shadow: 0 0 0 3px rgba(255,59,48,.18); } - -/* read-only score chip that matches .slo-inline-field look */ -.score-value{ - display:inline-block; - min-width:84px; - padding:6px 12px; - border-radius:999px; - background: rgba(255,255,255,.75); - color: var(--ink); - font-size:13px; - font-weight:800; - line-height:1; - text-align:center; - box-shadow: 0 1px 0 rgba(255,255,255,.45) inset; -} -@media (prefers-color-scheme: dark){ - .score-value{ - background: rgba(255,255,255,.10); - color: var(--ink); - box-shadow: 0 1px 0 rgba(255,255,255,.06) inset; - } -} - -/* Compact width for Energy and Runtime inputs in the controls bar */ -#energy_input.slo-inline-field, -#runtime_input.slo-inline-field{ - width:56px; /* was 84px */ - padding:6px 10px; /* keep pill feel while tighter */ - text-align:center; /* keep values readable in the narrow chip */ -} - - -.controls .slo-combo::before{ - content: none !important; - display: none !important; - width: 0 !important; - height: 0 !important; - margin: 0 !important; -} - -.controls .slo-combo{ padding-left: 8px; } - -/* Two-column layout for results + charts */ -.section-columns { - display: grid; - grid-template-columns: 0.9fr 1.6fr; - gap: var(--gap, 1rem); - align-items: start; -} - -/* Stack on small screens */ -@media (max-width: 900px) { - .section-columns { - grid-template-columns: 1fr; - } -} - -/* Add consistent space after the Current Topology card */ -.card > #topoTable, -.card > #topologyJSON { - /* Ensure these selectors target the topology card only */ -} -.card h3:contains("Current Topology") { - /* This selector is not supported in pure CSS, so use the card itself */ -} -/* Solution: target the first .card after .metrics for spacing */ -.card { - /* ...existing card styles... */ - /* Add margin-bottom for spacing after topology card */ - margin-bottom: 32px; -} - -/* Remove extra margin for other cards to avoid double spacing */ -.section-columns .card { - margin-bottom: 0; -} -.section-stack .card { - margin-bottom: 0; -} - -/* Normalize LLM feedback and priority styling */ -.llm-feedback-section { - background: white; - border: 1px solid #e5e7eb; - border-radius: 0.375rem; - padding: 1rem; - margin-bottom: 1rem; -} - -.llm-feedback-section h4 { - margin: 0 0 0.5rem 0; - font-size: 1rem; - font-weight: 600; - color: #374151; -} - -.feedback-value { - background: #f9fafb; - border: 1px solid #e5e7eb; - border-radius: 0.25rem; - padding: 0.5rem; - font-family: 'Inter', system-ui, -apple-system, sans-serif; - font-size: 0.875rem; - color: #374151; - display: inline-block; - min-width: 3rem; - text-align: center; -} - -.priority-section { - background: white; - border: 1px solid #e5e7eb; - border-radius: 0.375rem; - padding: 1rem; - margin-bottom: 1rem; -} - -.priority-section h4 { - margin: 0 0 0.5rem 0; - font-size: 1rem; - font-weight: 600; - color: #374151; -} - -.priority-value { - background: #f9fafb; - border: 1px solid #e5e7eb; - border-radius: 0.25rem; - padding: 0.5rem; - font-family: 'Inter', system-ui, -apple-system, sans-serif; - font-size: 0.875rem; - color: #374151; - display: inline-block; - min-width: 4rem; - text-align: center; -} - -/* Action type styling */ -.action-value { - font-weight: 600; - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - display: inline-block; - font-size: 0.875rem; -} - -.action-upscale { - background: #fef3c7; - color: #92400e; -} - -.action-slightly-upscale { - background: #fef7cd; - color: #a16207; -} - -.action-downscale { - background: #fecaca; - color: #b91c1c; -} - -.action-slightly-downscale { - background: #fed7d7; - color: #c53030; -} - -.action-maintain { - background: #d1fae5; - color: #065f46; -} - -/* Remove the blue background from existing elements */ -.card .bg-light-blue { - background: #f9fafb !important; - border: 1px solid #e5e7eb !important; - border-radius: 0.25rem !important; -} - diff --git a/services/dashboard/templates/index.html b/services/dashboard/templates/index.html deleted file mode 100644 index afe6281..0000000 --- a/services/dashboard/templates/index.html +++ /dev/null @@ -1,198 +0,0 @@ - - - - - OpenDT Digital Twin - - - - - - - - - -
- -
-

- -  OpenDT Digital Twin Dashboard -

-

Real-time datacenter simulation in rolling windows ยท LLM-guided topology optimization

-
- - -
-
- - -
- - - STOPPED -
- -
-
- - -
-
- -
Window โ€”
- -
-
- - -
-
- -
-
- - -
-
- -
-
- - โ€” -
-
- -
-
- - -
-{#
#} -{#

 PROCESSING

#} -{#
0
#} -{#
Optimization Cycles
#} -{#
#} - -
-

 WORKLOAD

-
0
-
Current Tasks
-
- -
-

 WORKLOAD

-
0
-
Current Fragments
-
- -
-

 ENERGY

-
โ€”
-
kWh Usage
-
- -
-

 CPU

-
โ€”
-
Utilization [%]
-
- -
-

 RUNTIME

-
โ€”
-
Estimated time
-
- -
-

 TOPOLOGY

-
0
-
Adjustment(s)
-
- -{#
#} -{#

 LLM

#} -{#
โ€”
#} -{#
โ€”
#} -{#
#} -
- - -
-
-
-

๐Ÿ—บ๏ธ Current Topology

-
- - - - - - - - - - - - - - - - -
ClusterHostCountCoresClock (MHz)Memory (GiB)
No topology
-
-
null
-
-
-
- - -
-

Power [kW]

-
- drag to zoom ยท double-click to reset -
-
-
- -
-

Average CPU Utilization [%]

- - -
- drag to zoom ยท double-click to reset -
- -
-
-
-
- - - {#
#} - {#

๐Ÿ… Best Configuration

#} - {#
#} - {#
Score: โ€”
#} - {#
#} - {#
null
#} - {#
#}
- - - - - - - - - - - - - - - diff --git a/services/dc-mock/README.md b/services/dc-mock/README.md index b0f1a56..68e5b41 100644 --- a/services/dc-mock/README.md +++ b/services/dc-mock/README.md @@ -1,301 +1,98 @@ -# dc-mock Service +# dc-mock -The **dc-mock** service simulates a real datacenter by replaying historical workload and power consumption data to Kafka topics. It acts as the data source for the entire OpenDT system. +Simulates a datacenter by replaying historical workload and power data to Kafka. -## Responsibilities +## Purpose -1. **Workload Replay**: Stream task submissions from `tasks.parquet` and `fragments.parquet` -2. **Power Telemetry**: Stream power consumption from `consumption.parquet` -3. **Topology Broadcasting**: Periodically publish datacenter topology from `topology.json` -4. **Heartbeat Generation**: Send periodic heartbeat messages for window synchronization +dc-mock acts as the data source for OpenDT. It reads Parquet files from the configured workload directory and publishes messages to Kafka topics, respecting the configured speed factor. -## Architecture +## Data Sources -``` -data/SURF/ -โ”œโ”€โ”€ tasks.parquet โ”€โ” -โ”œโ”€โ”€ fragments.parquet โ”€โ”คโ”€> WorkloadProducer โ”€> dc.workload -โ”œโ”€โ”€ consumption.parquet โ”€โ”คโ”€> PowerProducer โ”€โ”€โ”€> dc.power -โ””โ”€โ”€ topology.json โ”€โ”˜โ”€> TopologyProducer โ”€> dc.topology -``` +Reads from `workload//`: -### Producers +| File | Topic | Description | +|------|-------|-------------| +| tasks.parquet + fragments.parquet | dc.workload | Task submissions with execution profiles | +| consumption.parquet | dc.power | Power consumption telemetry | +| topology.json | dc.topology | Datacenter hardware configuration | -#### 1. WorkloadProducer +## Message Types -**File**: [`dc_mock/producers/workload_producer.py`](./dc_mock/producers/workload_producer.py) +### Workload Messages -- Reads `tasks.parquet` and `fragments.parquet` -- Joins tasks with their fragments -- Publishes `WorkloadMessage` objects to `dc.workload` -- Emits **heartbeat messages** every `heartbeat_frequency_minutes` (simulation time) -- Respects `speed_factor` for time progression +Published to `dc.workload`. Two types: -**Message Types**: -```python -# Task message +**Task message:** +``` { - "message_type": "task", - "timestamp": "2022-10-07T00:39:21", - "task": { - "id": 2132895, - "submission_time": "2022-10-07T00:39:21", - "duration": 12000, - "cpu_count": 16, - "cpu_capacity": 33600.0, - "mem_capacity": 100000, - "fragments": [...] - } + "message_type": "task", + "timestamp": "2022-10-07T00:39:21", + "task": { } } +``` -# Heartbeat message +**Heartbeat message:** +``` { - "message_type": "heartbeat", - "timestamp": "2022-10-07T00:45:00", - "task": null + "message_type": "heartbeat", + "timestamp": "2022-10-07T00:45:00", + "task": null } ``` -#### 2. PowerProducer - -**File**: [`dc_mock/producers/power_producer.py`](./dc_mock/producers/power_producer.py) +Heartbeats signal time progression to downstream consumers, enabling deterministic window closing. -- Reads `consumption.parquet` -- Publishes `Consumption` objects to `dc.power` -- Provides ground truth for comparing simulation predictions +### Power Messages -**Message Format**: -```python +Published to `dc.power`: +``` { - "power_draw": 19180.0, # Watts - "energy_usage": 575400.0, # Joules - "timestamp": "2022-10-08T06:35:30" + "power_draw": 19180.0, + "energy_usage": 575400.0, + "timestamp": "2022-10-08T06:35:30" } ``` -#### 3. TopologyProducer - -**File**: [`dc_mock/producers/topology_producer.py`](./dc_mock/producers/topology_producer.py) +### Topology Messages -- Reads `topology.json` -- Publishes `TopologySnapshot` to `dc.topology` every 30 seconds (real-time) -- Uses compacted topic with key `"datacenter"` to keep latest only - -**Message Format**: -```python +Published to `dc.topology` (compacted topic): +``` { - "timestamp": "2022-10-07T09:14:30", - "topology": { - "clusters": [...] - } + "timestamp": "2022-10-07T09:14:30", + "topology": { } } ``` ## Configuration -### Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `CONFIG_FILE` | Path to YAML configuration | `/app/config/simulation.yaml` | -| `WORKER_ID` | Unique producer identifier | `dc-mock-1` | - -### YAML Configuration - -**File**: `config/default.yaml` - -```yaml -workload: "SURF" # Maps to data/SURF/ - -simulation: - speed_factor: 300 # 300x real-time - heartbeat_frequency_minutes: 1 # Heartbeat every 1 minute (sim time) - -kafka: - bootstrap_servers: "kafka:29092" - topics: - workload: - name: "dc.workload" - power: - name: "dc.power" - topology: - name: "dc.topology" -``` - -### Speed Factor Behavior - -- `speed_factor: 1.0` - Real-time replay (1 second sim = 1 second real) -- `speed_factor: 300.0` - 300x faster (1 hour sim = 12 seconds real) -- `speed_factor: -1` - Maximum speed (no sleep between messages) - -**Formula**: -```python -sleep_time = (next_timestamp - current_timestamp) / speed_factor -``` - -## Data Format - -### Input Files - -Located in `data//`: - -#### `tasks.parquet` - -Required columns: -- `id` (int): Task identifier -- `submission_time` (datetime): When task was submitted -- `duration` (int): Task duration in milliseconds -- `cpu_count` (int): Number of CPUs requested -- `cpu_capacity` (float): CPU speed in MHz -- `mem_capacity` (int): Memory in MB - -#### `fragments.parquet` - -Required columns: -- `id` (int): Fragment identifier -- `task_id` (int): Parent task ID -- `duration` (int): Fragment duration in milliseconds -- `cpu_count` (int): CPUs used -- `cpu_usage` (float): CPU utilization value - -#### `consumption.parquet` - -Required columns: -- `timestamp` (datetime): Measurement time -- `power_draw` (float): Instantaneous power in Watts -- `energy_usage` (float): Accumulated energy in Joules +Settings under `services.dc-mock` in the config file: -#### `topology.json` +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| workload | string | "SURF" | Workload directory name under `workload/` | +| heartbeat_frequency_minutes | int | 1 | Heartbeat interval in simulation time | -See [Data Models documentation](../../docs/DATA_MODELS.md#topology-models) for schema. +### heartbeat_frequency_minutes -## Running +Controls how often dc-mock publishes heartbeat messages. Lower values provide more granular window closing but increase Kafka message volume. -### Via Docker Compose +## Workload Data Format -```bash -# Start all services -make up +Each workload directory must contain: -# View dc-mock logs -make logs-dc-mock - -# Or directly: -docker compose logs -f dc-mock -``` - -### Standalone (Development) - -```bash -cd services/dc-mock -source ../../.venv/bin/activate - -CONFIG_FILE=../../config/default.yaml \ -python -m dc_mock.main -``` - -## Heartbeat Mechanism - -### Purpose - -Heartbeats solves the problem: How does the consumer know when to close a window? - -Without heartbeats: -- Consumer can't distinguish between "no new tasks" and "Kafka delay" -- Windows might close prematurely or stay open indefinitely - -With heartbeats: -- Producer sends heartbeat every N minutes (simulation time) -- Consumer knows time has progressed even without task arrivals -- Windows can be closed deterministically based on heartbeat timestamps - -### Implementation - -```python -next_heartbeat_time = first_task_time.floor('1min') - -for task in sorted_tasks: - # Emit heartbeats for all minutes before this task - while next_heartbeat_time < task.submission_time: - heartbeat = WorkloadMessage( - message_type="heartbeat", - timestamp=next_heartbeat_time, - task=None - ) - publish(heartbeat) - next_heartbeat_time += timedelta(minutes=heartbeat_frequency) - - # Emit the task - task_message = WorkloadMessage( - message_type="task", - timestamp=task.submission_time, - task=task - ) - publish(task_message) ``` - -### Configuration - -```yaml -simulation: - heartbeat_frequency_minutes: 1 # Send heartbeat every 1 minute (sim time) +workload/SURF/ +โ”œโ”€โ”€ tasks.parquet # Task definitions +โ”œโ”€โ”€ fragments.parquet # Task execution profiles +โ”œโ”€โ”€ consumption.parquet # Actual power measurements +โ”œโ”€โ”€ carbon.parquet # Grid carbon intensity +โ””โ”€โ”€ topology.json # Datacenter topology ``` -**Trade-offs**: -- **Shorter frequency** (e.g., 1 minute): More accurate window closing, more Kafka messages -- **Longer frequency** (e.g., 5 minutes): Fewer messages, less precise window boundaries - -## Monitoring - -### Logs +To use a custom workload, create a directory with these files and set `services.dc-mock.workload` to the directory name. -```bash -# View producer activity -docker compose logs -f dc-mock +## Logs -# Expected output: -# INFO - Starting WorkloadProducer... -# INFO - Loaded 12,345 tasks from tasks.parquet -# INFO - Starting PowerProducer... -# INFO - Starting TopologyProducer... -# INFO - Published heartbeat: 2022-10-07T00:01:00 -# INFO - Published task 2132895 to dc.workload -# INFO - Published power telemetry to dc.power: 19180.0 W ``` - -### Kafka Topic Inspection - -```bash -# List topics -make kafka-topics - -# Consume from workload topic -docker exec -it opendt-kafka kafka-console-consumer \ - --bootstrap-server localhost:9092 \ - --topic dc.workload \ - --from-beginning \ - --max-messages 10 - -# Consume from topology topic (compacted) -docker exec -it opendt-kafka kafka-console-consumer \ - --bootstrap-server localhost:9092 \ - --topic dc.topology \ - --from-beginning -``` - -## Testing - -```bash -# Run tests -cd services/dc-mock -pytest - -# Run specific test -pytest tests/test_producers.py::test_workload_producer +make logs-dc-mock ``` - -## Related Documentation - -- [Architecture Overview](../../docs/ARCHITECTURE.md) - System design -- [Data Models](../../docs/DATA_MODELS.md) - Message formats -- [Simulation Worker](../sim-worker/README.md) - Consumer of these messages diff --git a/services/grafana/README.md b/services/grafana/README.md new file mode 100644 index 0000000..552dea0 --- /dev/null +++ b/services/grafana/README.md @@ -0,0 +1,43 @@ +# Grafana + +Visualization dashboards for OpenDT metrics. + +## Access + +- **URL:** http://localhost:3000 +- **Authentication:** Disabled (anonymous admin access) + +The OpenDT dashboard is set as the home page. + +## Dashboards + +### Power Dashboard + +Displays: +- **Power Consumption** - Actual vs simulated power draw over time +- **Carbon Emissions** - Estimated carbon emissions based on power and grid intensity + +Data is queried from the OpenDT API at `http://api:8000`. + +## Provisioning + +Dashboards in `provisioning/dashboards/` are auto-provisioned on startup. These are read-only templates. + +To create an editable copy: +1. Open the dashboard +2. Click title โ†’ **Save As** +3. Save with a new name + +Custom dashboards are persisted in the Docker volume. + +## Data Sources + +The [Infinity datasource](https://grafana.com/grafana/plugins/yesoreyeram-infinity-datasource/) is pre-configured to query JSON endpoints from the OpenDT API. + +## Persistence + +Dashboard data is stored in the `opendt-grafana-storage` Docker volume. To reset: + +``` +make clean-volumes +``` diff --git a/grafana/provisioning/dashboards/dashboards.yaml b/services/grafana/provisioning/dashboards/dashboards.yaml similarity index 79% rename from grafana/provisioning/dashboards/dashboards.yaml rename to services/grafana/provisioning/dashboards/dashboards.yaml index f7cec6a..191b67f 100644 --- a/grafana/provisioning/dashboards/dashboards.yaml +++ b/services/grafana/provisioning/dashboards/dashboards.yaml @@ -9,4 +9,4 @@ providers: updateIntervalSeconds: 10 allowUiUpdates: true options: - path: /etc/grafana/provisioning/dashboards \ No newline at end of file + path: /etc/grafana/provisioning/dashboards diff --git a/services/grafana/provisioning/dashboards/power_dashboard.json b/services/grafana/provisioning/dashboards/power_dashboard.json new file mode 100644 index 0000000..4436cc6 --- /dev/null +++ b/services/grafana/provisioning/dashboards/power_dashboard.json @@ -0,0 +1,449 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Real-time monitoring of datacenter power consumption and carbon emissions", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "df5tfgbpfeakgc" + }, + "description": "Comparison of simulated vs actual power consumption", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Power (kW)", + "axisPlacement": "left", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 32, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "actual_power_kw" + }, + "properties": [ + { + "id": "displayName", + "value": "Actual Power" + }, + { + "id": "color", + "value": { + "fixedColor": "#73BF69", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "simulated_power_kw" + }, + "properties": [ + { + "id": "displayName", + "value": "Simulated Power (OpenDT)" + }, + { + "id": "color", + "value": { + "fixedColor": "#F2495C", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/.*_power$/" + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": true, + "tooltip": true, + "viz": true + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["mean", "max", "min"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "columns": [], + "computed_columns": [ + { + "selector": "actual_power/1000", + "text": "actual_power_kw", + "type": "number" + }, + { + "selector": "simulated_power/1000", + "text": "simulated_power_kw", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "df5tfgbpfeakgc" + }, + "filters": [], + "format": "table", + "global_query_id": "", + "parser": "backend", + "refId": "A", + "root_selector": "data", + "source": "url", + "type": "json", + "url": "http://api:8000/api/power?interval_seconds=60", + "url_options": { + "data": "", + "method": "GET" + } + } + ], + "title": "Power Consumption", + "transformations": [ + { + "id": "convertFieldType", + "options": { + "conversions": [ + { + "destinationType": "time", + "targetField": "timestamp" + } + ], + "fields": {} + } + } + ], + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "df5tfgbpfeakgc" + }, + "description": "Carbon emissions based on power draw and grid carbon intensity", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 15, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "carbon_emission_kg" + }, + "properties": [ + { + "id": "displayName", + "value": "Carbon Emission" + }, + { + "id": "color", + "value": { + "fixedColor": "#8F3BB8", + "mode": "fixed" + } + }, + { + "id": "unit", + "value": "massg" + }, + { + "id": "custom.axisLabel", + "value": "Carbon Emission (kg COโ‚‚/h)" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "carbon_intensity" + }, + "properties": [ + { + "id": "displayName", + "value": "Grid Carbon Intensity" + }, + { + "id": "color", + "value": { + "fixedColor": "#FF9830", + "mode": "fixed" + } + }, + { + "id": "unit", + "value": "gCOโ‚‚/kWh" + }, + { + "id": "custom.axisPlacement", + "value": "right" + }, + { + "id": "custom.axisLabel", + "value": "Carbon Intensity (gCOโ‚‚/kWh)" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "carbon_emission" + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": true, + "tooltip": true, + "viz": true + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "power_draw" + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": true, + "tooltip": true, + "viz": true + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 10 + }, + "id": 2, + "options": { + "legend": { + "calcs": ["mean", "max", "min"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "columns": [], + "computed_columns": [ + { + "selector": "carbon_emission/1000", + "text": "carbon_emission_kg", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "df5tfgbpfeakgc" + }, + "filters": [], + "format": "table", + "global_query_id": "", + "parser": "backend", + "refId": "A", + "root_selector": "data", + "source": "url", + "type": "json", + "url": "http://api:8000/api/carbon_emission?interval_seconds=60", + "url_options": { + "data": "", + "method": "GET" + } + } + ], + "title": "Carbon Emissions", + "transformations": [ + { + "id": "convertFieldType", + "options": { + "conversions": [ + { + "destinationType": "time", + "targetField": "timestamp" + } + ], + "fields": {} + } + } + ], + "type": "timeseries" + } + ], + "preload": false, + "refresh": "5s", + "schemaVersion": 42, + "tags": ["opendt", "power", "carbon"], + "templating": { + "list": [] + }, + "time": { + "from": "2022-10-06T22:02:30.000Z", + "to": "2022-10-09T06:44:30.000Z" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h"] + }, + "timezone": "browser", + "title": "OpenDT Dashboard", + "uid": "opendt-main", + "version": 1 +} diff --git a/grafana/provisioning/datasources/datasources.yaml b/services/grafana/provisioning/datasources/datasources.yaml similarity index 100% rename from grafana/provisioning/datasources/datasources.yaml rename to services/grafana/provisioning/datasources/datasources.yaml diff --git a/services/kafka-init/README.md b/services/kafka-init/README.md index 2251f95..ddb710e 100644 --- a/services/kafka-init/README.md +++ b/services/kafka-init/README.md @@ -1,442 +1,64 @@ -# kafka-init Service +# kafka-init -The **kafka-init** service is an infrastructure initialization container that creates and configures Kafka topics before application services start. It ensures all required topics exist with proper retention, compaction, and partitioning settings. +Creates and configures Kafka topics before application services start. -## Architecture +## Purpose -``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ kafka-init Container โ”‚ -โ”‚ โ”‚ -โ”‚ 1. Read config/default.yaml โ”‚ -โ”‚ 2. Parse topic definitions โ”‚ -โ”‚ 3. Connect to Kafka (with retries) โ”‚ -โ”‚ 4. Create topics if not exist โ”‚ -โ”‚ 5. Apply topic configurations โ”‚ -โ”‚ 6. Exit (0 = success, 1 = failure) โ”‚ -โ”‚ โ”‚ -โ”‚ Blocks other services until complete โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - v - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ Kafka Broker โ”‚ - โ”‚ Topics Created: โ”‚ - โ”‚ โ€ข dc.workload โ”‚ - โ”‚ โ€ข dc.power โ”‚ - โ”‚ โ€ข dc.topology โ”‚ - โ”‚ โ€ข sim.topology โ”‚ - โ”‚ โ€ข sim.results โ”‚ - โ”‚ โ€ข sys.config โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` +kafka-init is an initialization container that ensures all required Kafka topics exist with proper retention and compaction settings. It runs once and exits, blocking other services until topics are ready. -## Configuration +## Topics Created + +| Topic | Type | Retention | Purpose | +|-------|------|-----------|---------| +| dc.workload | Stream | 24 hours | Task submissions and heartbeats | +| dc.power | Stream | 1 hour | Power consumption telemetry | +| dc.topology | Compacted | - | Real datacenter topology | +| sim.topology | Compacted | - | Calibrated topology | +| sim.results | Stream | 7 days | Simulation predictions | +| sys.config | Compacted | - | Runtime configuration | -### Environment Variables +### Topic Types + +**Stream topics:** Retain all messages for a configured duration. Used for event data. -| Variable | Description | Default | -|----------|-------------|---------| -| `CONFIG_FILE` | Path to YAML configuration | `/app/config/simulation.yaml` | -| `PYTHONUNBUFFERED` | Unbuffered Python output | `1` | +**Compacted topics:** Keep only the latest value per key. Used for state data (topology, configuration). -### Topic Configuration +## Configuration -**File**: `config/default.yaml` +Topics are configured under `kafka.topics` in the config file: ```yaml kafka: - bootstrap_servers: "kafka:29092" topics: workload: name: "dc.workload" config: - retention.ms: "86400000" # 24 hours - - power: - name: "dc.power" - config: - retention.ms: "3600000" # 1 hour - + retention.ms: "86400000" topology: name: "dc.topology" config: cleanup.policy: "compact" - min.compaction.lag.ms: "3600000" # 1 hour - - sim_topology: - name: "sim.topology" - config: - cleanup.policy: "compact" - min.compaction.lag.ms: "0" # Immediate compaction - - system: - name: "sys.config" - config: - cleanup.policy: "compact" - - results: - name: "sim.results" - config: - retention.ms: "604800000" # 7 days -``` - -### Topic Types - -**Stream Topics** (retention-based): -- `dc.workload` - Task submissions (24h retention) -- `dc.power` - Power telemetry (1h retention) -- `sim.results` - Simulation predictions (7d retention) - -**Compacted Topics** (key-based): -- `dc.topology` - Real datacenter topology -- `sim.topology` - Simulated topology -- `sys.config` - Runtime configuration - -## Implementation - -### Main Flow - -**File**: [`kafka_init/main.py`](./kafka_init/main.py) - -```python -def main(): - # 1. Load configuration - config = load_config_from_env() - - # 2. Wait for Kafka to be ready - admin_client = wait_for_kafka(config) - - # 3. Create topics - for topic_key, topic_config in config.kafka.topics.items(): - create_topic_if_not_exists(admin_client, topic_config) - - # 4. Exit successfully - sys.exit(0) ``` -### Kafka Connection Retry +### Topic Configuration Options -```python -def wait_for_kafka(config, max_retries=30, retry_delay=2): - """Wait for Kafka broker to be ready.""" - for attempt in range(max_retries): - try: - admin = KafkaAdminClient( - bootstrap_servers=config.kafka.bootstrap_servers, - client_id="kafka-init" - ) - admin.list_topics() # Test connection - return admin - except KafkaError: - if attempt < max_retries - 1: - time.sleep(retry_delay) - else: - raise -``` +| Option | Description | +|--------|-------------| +| retention.ms | How long messages are retained (milliseconds) | +| cleanup.policy | "delete" (time-based) or "compact" (key-based) | +| min.compaction.lag.ms | Minimum time before compaction | -### Topic Creation +## Startup Flow -```python -def create_topic_if_not_exists(admin, topic_config): - """Create topic with specified configuration.""" - topic_name = topic_config.name - - # Check if topic already exists - existing_topics = admin.list_topics() - if topic_name in existing_topics: - logger.info(f"Topic {topic_name} already exists") - return - - # Create topic with config - new_topic = NewTopic( - name=topic_name, - num_partitions=topic_config.get("partitions", 1), - replication_factor=topic_config.get("replication_factor", 1), - topic_configs=topic_config.config or {} - ) - - admin.create_topics([new_topic]) - logger.info(f"โœ… Created topic: {topic_name}") -``` - -## Running +1. Wait for Kafka to be healthy (with retries) +2. Create topics if they don't exist +3. Apply topic configurations +4. Exit with code 0 (success) or 1 (failure) -### Via Docker Compose (Automatic) +Other services wait for kafka-init to complete before starting. -```bash -# Start services (kafka-init runs automatically) -make up +## Logs -# kafka-init runs before other services start -# It will exit once topics are created ``` - -### Check Status - -```bash -# View kafka-init logs -docker compose logs kafka-init - -# Expected output: -# INFO - Loading configuration from /app/config/simulation.yaml -# INFO - Connecting to Kafka at kafka:29092 -# INFO - โœ… Created topic: dc.workload -# INFO - โœ… Created topic: dc.power -# INFO - โœ… Created topic: dc.topology -# INFO - โœ… Created topic: sim.topology -# INFO - โœ… Created topic: sys.config -# INFO - โœ… Created topic: sim.results -# INFO - All topics created successfully - -# List created topics -make kafka-topics -# Or: -docker exec -it opendt-kafka kafka-topics \ - --bootstrap-server localhost:9092 \ - --list -``` - -### Standalone (Development) - -```bash -cd services/kafka-init -source ../../.venv/bin/activate - -# Set environment -export CONFIG_FILE=../../config/default.yaml - -# Run initialization -python -m kafka_init.main -``` - -### Verify Topic Configuration - -```bash -# Describe specific topic -docker exec -it opendt-kafka kafka-topics \ - --bootstrap-server localhost:9092 \ - --describe \ - --topic dc.topology - -# Output shows: -# Topic: dc.topology -# PartitionCount: 1 -# ReplicationFactor: 1 -# Configs: cleanup.policy=compact,min.compaction.lag.ms=3600000 -``` - -## Docker Compose Integration - -### Dependency Chain - -```yaml -services: - kafka-init: - depends_on: - kafka: - condition: service_healthy - restart: "no" # Don't restart (runs once) - - dc-mock: - depends_on: - kafka-init: - condition: service_completed_successfully - - sim-worker: - depends_on: - kafka-init: - condition: service_completed_successfully -``` - -**Flow**: -1. Kafka starts and waits for health check -2. kafka-init starts and creates topics -3. kafka-init exits with code 0 (success) -4. Application services (dc-mock, sim-worker) start - -## Exit Codes - -| Code | Meaning | Action | -|------|---------|--------| -| 0 | Success - All topics created | Services start | -| 1 | Failure - Topic creation failed | Services blocked, check logs | - -## Troubleshooting - -### Issue: "Connection refused to Kafka" - -**Cause**: Kafka not ready yet - -**Solution**: kafka-init automatically retries for 60 seconds. Check Kafka logs: -```bash -docker compose logs kafka | grep "started" -``` - -### Issue: "Topic already exists" - -**Cause**: Normal behavior if topics were created previously - -**Solution**: No action needed. kafka-init skips existing topics. - -### Issue: Services not starting after kafka-init - -**Cause**: kafka-init exited with error (code 1) - -**Solution**: -```bash -# Check kafka-init logs docker compose logs kafka-init - -# Look for error messages -# Common issues: -# - Invalid topic config (fix config/default.yaml) -# - Kafka permissions (check Kafka ACLs) -# - Network issues (verify Docker network) - -# Fix issue and restart -make down -make up -``` - -### Issue: "Invalid topic configuration" - -**Cause**: Syntax error in `config/default.yaml` - -**Solution**: -```bash -# Validate YAML syntax -python -c "import yaml; yaml.safe_load(open('config/default.yaml'))" - -# Check topic config format: -# - retention.ms must be string: "86400000" -# - cleanup.policy must be "delete" or "compact" -``` - -## Topic Configuration Reference - -### Retention Policies - -**Time-based retention** (stream topics): -```yaml -config: - retention.ms: "86400000" # Keep messages for 24 hours -``` - -**Size-based retention**: -```yaml -config: - retention.bytes: "1073741824" # Keep up to 1GB -``` - -**Compaction** (key-based retention): -```yaml -config: - cleanup.policy: "compact" # Keep latest value per key - min.compaction.lag.ms: "3600000" # Wait 1h before compacting -``` - -### Partitioning - -```yaml -workload: - name: "dc.workload" - partitions: 4 # 4 partitions for parallel consumption - replication_factor: 1 # 1 replica (single-broker cluster) -``` - -**Considerations**: -- More partitions = higher throughput -- Partitions enable parallel consumption -- Replication factor must be โ‰ค number of brokers - -### Other Settings - -```yaml -config: - # Message size limits - max.message.bytes: "10485760" # 10MB max message - - # Compression - compression.type: "gzip" - - # Segment rolling - segment.ms: "86400000" # New segment every 24h -``` - -## Development - -### Adding a New Topic - -1. **Update config**: -```yaml -# In config/default.yaml -kafka: - topics: - my_new_topic: - name: "my.new.topic" - config: - retention.ms: "3600000" -``` - -2. **Update common library** (if needed): -```python -# In libs/common/opendt_common/config.py -class KafkaConfig(BaseModel): - topics: dict[str, TopicConfig] - - class Topics: - my_new_topic: TopicConfig -``` - -3. **Restart services**: -```bash -make down -make up -``` - -4. **Verify creation**: -```bash -make kafka-topics -# Should see "my.new.topic" in list -``` - -### Testing - -```bash -# Test configuration parsing -python -m pytest libs/common/tests/test_config.py - -# Test topic creation (requires Kafka) -docker compose up -d kafka -docker compose run --rm kafka-init -docker compose down -``` - -## Monitoring - -### Logs - -```bash -# View logs -docker compose logs kafka-init - -# Successful run: -# INFO - Connecting to Kafka at kafka:29092 -# INFO - โœ… Created topic: dc.workload -# INFO - โœ… Created topic: dc.power -# INFO - All topics created successfully - -# Failed run: -# ERROR - Failed to create topic dc.workload: TopicExistsError -# ERROR - Topic creation failed -``` - -### Container Status - -```bash -# Check if kafka-init completed successfully -docker compose ps kafka-init - -# STATUS should show "exited (0)" ``` diff --git a/services/simulator/README.md b/services/simulator/README.md index 5c525f3..cdbef84 100644 --- a/services/simulator/README.md +++ b/services/simulator/README.md @@ -1,571 +1,98 @@ -# sim-worker Service +# simulator -The **sim-worker** is the core simulation engine of OpenDT. It consumes workload and topology streams from Kafka, aggregates tasks into time windows, invokes the OpenDC simulator, and outputs power consumption predictions. +Core simulation engine that consumes workload data and produces power predictions using OpenDC. -## Architecture +## Purpose -``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ sim-worker โ”‚ -โ”‚ โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ Kafka โ”‚โ”€โ”€โ”€โ”€>โ”‚ Window Manager โ”‚ โ”‚ -โ”‚ โ”‚ Consumer โ”‚ โ”‚ - Aggregation โ”‚ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ - Heartbeats โ”‚ โ”‚ -โ”‚ โ”‚ Topics: โ”‚ โ”‚ - Closing โ”‚ โ”‚ -โ”‚ โ”‚ - workload โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ - topology โ”‚ โ”‚ โ”‚ -โ”‚ โ”‚ - sim.topo โ”‚ v โ”‚ -โ”‚ โ”‚ - power โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ OpenDC Runner โ”‚ โ”‚ -โ”‚ โ”‚ - Parquet I/O โ”‚ โ”‚ -โ”‚ โ”‚ - Subprocess โ”‚ โ”‚ -โ”‚ โ”‚ - Parsing โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ โ”‚ -โ”‚ v โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ Result Cache โ”‚ โ”‚ -โ”‚ โ”‚ Experiment Mgr โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ โ”‚ -โ”‚ v โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ Kafka Topic Local Files โ”‚ -โ”‚ sim.results (debug/experiment) โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` - -## Core Components - -### 1. Main Worker (`main.py`) - -**Responsibilities**: -- Kafka consumer event loop -- Message routing (workload, topology, sim.topology, power) -- Window lifecycle management -- Mode selection (normal/debug/experiment) -- Coordination between components - -**Key Classes**: -- `SimulationWorker`: Main orchestrator - -### 2. OpenDC Runner (`runner/opendc_runner.py`) - -**Responsibilities**: -- Convert Pydantic models to OpenDC input formats -- Invoke OpenDC binary via subprocess -- Parse OpenDC output Parquet files -- Return structured results with timeseries - -**Key Classes**: -- `OpenDCRunner`: Simulation invocation and I/O -- `SimulationResults`: Structured output model -- `TimeseriesData`: Power/CPU timeseries points - -**Input Files Created**: -- `experiment.json` - OpenDC experiment configuration -- `topology.json` - Datacenter topology -- `tasks.parquet` - Task definitions (int32 IDs, non-nullable) -- `fragments.parquet` - Task execution profiles - -**Output Files Parsed**: -- `powerSource.parquet` - Power consumption over time -- `host.parquet` - Host-level metrics -- `service.parquet` - Service-level metrics - -### 3. Window Manager (`window_manager.py`) - -**Responsibilities**: -- Event-time windowing based on task submission timestamps -- Contiguous window creation (no gaps) -- Heartbeat-based window closing -- Task accumulation per window -- Topology tracking - -**Key Classes**: -- `TimeWindow`: Individual window with tasks and metadata -- `WindowManager`: Window lifecycle management - -**Windowing Logic**: -``` -First task at 22:13:15 โ†’ Create window [22:13:00 - 22:18:00) -Heartbeat at 22:18:00 โ†’ Close window 0, create window 1 [22:18:00 - 22:23:00) -Task at 22:31:45 โ†’ Close windows 1-3, create window 4 [22:28:00 - 22:33:00) -``` - -### 4. Result Cache (`result_cache.py`) - -**Responsibilities**: -- Cache simulation results based on inputs -- Avoid redundant OpenDC invocations -- Invalidate cache on topology changes - -**Caching Strategy**: -```python -cache_key = SHA256(topology_json) + cumulative_task_count - -if cache.can_reuse(topology, task_count): - return cache.get_cached_results() -else: - results = run_simulation(...) - cache.update(topology, task_count, results) -``` - -**Invalidation**: -- When simulated topology updated via API -- Cache cleared manually via `cache.clear()` - -### 5. Experiment Manager (`experiment_manager.py`) - -**Responsibilities** (Experiment Mode Only): -- Record actual power from `dc.power` -- Write simulation results to Parquet -- Archive OpenDC I/O files per window -- Generate power comparison plots - -**Output Structure**: -``` -output/ -โ””โ”€โ”€ my_experiment/ - โ””โ”€โ”€ run_1/ - โ”œโ”€โ”€ results.parquet - โ”œโ”€โ”€ power_plot.png - โ””โ”€โ”€ opendc/ - โ””โ”€โ”€ window_0000/ - โ”œโ”€โ”€ input/ - โ”‚ โ”œโ”€โ”€ summary.json - โ”‚ โ”œโ”€โ”€ experiment.json - โ”‚ โ”œโ”€โ”€ topology.json - โ”‚ โ”œโ”€โ”€ tasks.parquet - โ”‚ โ””โ”€โ”€ fragments.parquet - โ””โ”€โ”€ output/ - โ”œโ”€โ”€ summary.json - โ”œโ”€โ”€ powerSource.parquet - โ”œโ”€โ”€ host.parquet - โ””โ”€โ”€ service.parquet -``` - -## Simulation Flow - -### 1. Task Ingestion - -```python -# WorkloadMessage received from dc.workload -message = { - "message_type": "task", # or "heartbeat" - "timestamp": "2022-10-07T00:39:21", - "task": { /* Task object */ } -} - -# Route to window manager -window_manager.add_task(task) -``` - -### 2. Window Closing - -```python -# Heartbeat received -heartbeat = { - "message_type": "heartbeat", - "timestamp": "2022-10-07T00:45:00" -} - -# Close all windows ending before heartbeat timestamp -closed_windows = window_manager.close_windows_before(heartbeat.timestamp) - -# Process each closed window -for window in closed_windows: - process_window(window) -``` - -### 3. Simulation Invocation - -```python -# Collect cumulative tasks (all from beginning) -all_tasks = [] -for w in windows[0:current_window_id + 1]: - all_tasks.extend(w.tasks) - -# Check cache -if result_cache.can_reuse(simulated_topology, len(all_tasks)): - results = result_cache.get_cached_results() - logger.info("โœ… Using cached results") -else: - # Run OpenDC - results = opendc_runner.run_simulation( - tasks=all_tasks, - topology=simulated_topology, - experiment_name=f"window-{window.window_id}-simulated" - ) - result_cache.update(simulated_topology, len(all_tasks), results) -``` - -### 4. Result Handling +The simulator aggregates tasks into time windows, invokes the OpenDC simulator, and outputs power consumption predictions. It maintains cumulative state to ensure accurate long-running simulations. -**Normal Mode**: -```python -# Publish to Kafka -send_message( - producer=producer, - topic="sim.results", - message=results.model_dump(mode="json") -) -``` - -**Debug Mode**: -```python -# Write to local files -output_dir = f"output/run-{worker_id}-{timestamp}/window_{window_id:04d}/" -Path(output_dir).mkdir(parents=True, exist_ok=True) +## Processing Flow -with open(output_dir / "results.json", "w") as f: - json.dump(results.model_dump(mode="json"), f, indent=2) -``` +1. **Consume** - Read tasks from `dc.workload` topic +2. **Aggregate** - Group tasks into time windows based on submission timestamp +3. **Close** - When heartbeat timestamp exceeds window end, close the window +4. **Simulate** - Invoke OpenDC with all tasks from beginning (cumulative) +5. **Output** - Write results to `agg_results.parquet` -**Experiment Mode**: -```python -# Write to parquet + generate plot -experiment_manager.write_simulation_results(window, results, all_tasks) -experiment_manager.archive_opendc_files(window, results, all_tasks) -experiment_manager.generate_power_plot() -``` +## Windowing -## Configuration +Tasks are grouped into windows based on `simulation_frequency_minutes`: -### Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `CONFIG_FILE` | Path to YAML configuration | `/app/config/simulation.yaml` | -| `WORKER_ID` | Unique worker identifier | `worker-1` | -| `CONSUMER_GROUP` | Kafka consumer group | `sim-workers` | -| `DEBUG_MODE` | Enable debug mode | `false` | -| `EXPERIMENT_NAME` | Experiment identifier | `default` | -| `EXPERIMENT_OUTPUT_DIR` | Experiment output path | `/app/output` | - -### YAML Configuration - -**File**: `config/default.yaml` - -```yaml -simulation: - window_size_minutes: 5 - heartbeat_frequency_minutes: 1 - experiment_mode: false # Set true for experiment mode - -kafka: - bootstrap_servers: "kafka:29092" - topics: - workload: - name: "dc.workload" - topology: - name: "dc.topology" - sim_topology: - name: "sim.topology" - power: - name: "dc.power" - results: - name: "sim.results" ``` - -## Operating Modes - -### Normal Mode - -```bash -make up +Window 0: [00:00 - 00:05) โ†’ Tasks with submission_time in this range +Window 1: [00:05 - 00:10) โ†’ ... ``` -- Publishes results to Kafka -- No local files -- Production mode +Windows close when a heartbeat arrives with a timestamp past the window end. -### Debug Mode +### Cumulative Simulation -```bash -make up-debug -``` - -- Writes JSON files per window -- No Kafka publishing -- Development mode - -### Experiment Mode - -```bash -make experiment name=my_experiment -``` - -- Writes Parquet + plots -- Archives OpenDC I/O -- Research mode +Each window simulates all tasks from the beginning of the workload, not just tasks in that window. This ensures accurate power predictions for long-running workloads. ## OpenDC Integration -### Binary Location - -``` -services/sim-worker/opendc/bin/OpenDCExperimentRunner/bin/OpenDCExperimentRunner -``` - -### Java Requirements - -- **Version**: OpenJDK 21 -- **JAVA_HOME**: Auto-detected at runtime - - macOS: `/usr/libexec/java_home` - - Linux: `/usr/lib/jvm/java-21-openjdk-amd64` - -### Invocation - -```python -result = subprocess.run( - [opendc_binary, "--experiment-path", experiment_json_path], - env={"JAVA_HOME": detected_java_home}, - capture_output=True, - text=True, - timeout=120 -) -``` - -### Input Schema - -**experiment.json**: -```json -{ - "name": "window-0-simulated", - "topologies": [{"pathToFile": "/tmp/opendc-.../topology.json"}], - "workloads": [{ - "pathToFile": "/tmp/opendc-.../workload", - "type": "ComputeWorkload" - }], - "exportModels": [{ - "exportInterval": 150, - "filesToExport": ["powerSource", "host", "task", "service"], - "computeExportConfig": { - "powerSourceExportColumns": ["energy_usage", "power_draw"] - } - }], - "outputFolder": "/tmp/opendc-.../output" -} -``` - -### Output Parsing - -```python -# Read powerSource.parquet for timeseries -power_table = pq.read_table(output_dir / "powerSource.parquet") -power_df = power_table.to_pandas() - -# Extract energy and power -energy_kwh = power_df["energy_usage"].sum() / 3_600_000 -max_power = power_df["power_draw"].max() - -# Build timeseries -power_draw_series = [ - TimeseriesData(timestamp=int(row["timestamp"]), value=float(row["power_draw"])) - for _, row in power_df.iterrows() -] -``` - -## Running - -### Via Docker Compose - -```bash -# Start all services -make up - -# View sim-worker logs -make logs-sim-worker - -# Execute command in container -docker compose exec sim-worker bash -``` - -### Standalone (Development) - -```bash -cd services/sim-worker -source ../../.venv/bin/activate - -# Set environment -export CONFIG_FILE=../../config/default.yaml -export WORKER_ID=dev-worker -export EXPERIMENT_OUTPUT_DIR=../../output - -# Run worker -python -m sim_worker.main -``` - -### Testing - -```bash -cd services/sim-worker -pytest tests/ - -# Run specific test -pytest tests/test_opendc_simple.py -v - -# With detailed logs -pytest tests/ -o log_cli=true -o log_cli_level=DEBUG -``` +The simulator invokes the OpenDC binary to perform actual power calculations: -## Monitoring - -### Logs - -```bash -# Tail logs -docker compose logs -f sim-worker - -# Expected output: -# INFO - Initialized SimulationWorker 'worker-1' -# INFO - Subscribed: dc.workload, dc.topology, sim.topology -# INFO - ๐Ÿ“ฆ Created window 0: [2022-10-07 00:00:00 - 2022-10-07 00:05:00) -# INFO - ๐Ÿ”’ Closed window 0 with 42 tasks -# INFO - Running simulation for window 0 with 42 cumulative tasks -# INFO - โœ… Simulation (simulated) for window 0: energy=1.649 kWh ``` - -### Metrics - -```bash -# Check window statistics (from logs) -docker compose logs sim-worker | grep "Stats:" - -# Output: -# INFO - ๐Ÿ“Š Stats: 314 tasks processed, 35 windows simulated +opendc/bin/OpenDCExperimentRunner ``` -### Kafka Lag - -```bash -# Check consumer lag -docker exec -it opendt-kafka kafka-consumer-groups \ - --bootstrap-server localhost:9092 \ - --describe \ - --group sim-workers -``` +### Input Files -## Troubleshooting - -### Issue: "OpenDC simulation failed with exit code 1" - -**Cause**: Invalid input data or topology - -**Solution**: -1. Enable debug mode: `make up-debug` -2. Check `output/run-*/window_*/tasks.json` for task data -3. Verify topology structure -4. Check OpenDC logs in sim-worker container - -### Issue: "JAVA_HOME is set to an invalid directory" - -**Cause**: Java not installed or wrong path - -**Solution**: -```bash -# Check Java in container -docker compose exec sim-worker java -version +Created for each simulation run: +- `experiment.json` - OpenDC configuration +- `topology.json` - Datacenter topology +- `tasks.parquet` - Task definitions +- `fragments.parquet` - Task execution profiles -# Should show: openjdk version "21" +### Output Files -# If missing, rebuild: -make down -make up build=true -``` +Parsed after simulation: +- `powerSource.parquet` - Power consumption over time +- `host.parquet` - Host-level metrics -### Issue: Windows staying open indefinitely +## Output -**Cause**: No heartbeats or heartbeat frequency too long +Results are written to `data//simulator/`: -**Solution**: -```yaml -# In config file -simulation: - heartbeat_frequency_minutes: 1 # Decrease if needed ``` - -### Issue: Cache not working (redundant simulations) - -**Cause**: Topology changing between windows - -**Solution**: -```bash -# Check logs for cache hits -docker compose logs sim-worker | grep "cached" - -# Verify topology stability -docker exec -it opendt-kafka kafka-console-consumer \ - --bootstrap-server localhost:9092 \ - --topic sim.topology \ - --from-beginning +simulator/ +โ”œโ”€โ”€ agg_results.parquet # Aggregated power predictions +โ””โ”€โ”€ opendc/ + โ””โ”€โ”€ run_/ # Individual simulation archives + โ”œโ”€โ”€ input/ + โ””โ”€โ”€ output/ ``` -## Performance +### agg_results.parquet -### Throughput +| Column | Description | +|--------|-------------| +| timestamp | Simulation timestamp | +| power_draw | Predicted power in Watts | +| carbon_intensity | Grid carbon intensity | -- **Windows/minute**: ~10-20 (depends on OpenDC speed) -- **OpenDC invocation time**: ~2-5 seconds per window -- **Caching improvement**: 95%+ time savings when topology stable - -### Resource Usage - -- **Memory**: ~1-2 GB (includes OpenDC subprocess) -- **CPU**: Medium (OpenDC is CPU-intensive during simulation) -- **Disk**: Variable (experiment mode creates large archives) +## Configuration -### Optimization Tips +Settings under `services.simulator` in the config file: -1. **Enable caching**: Ensure topology doesn't change unnecessarily -2. **Longer windows**: Use 15-minute windows for fewer simulations -3. **Increase heartbeat frequency**: Reduce Kafka message overhead -4. **Parquet compression**: Reduces I/O time +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| simulation_frequency_minutes | int | 5 | Window size for task aggregation | +| background_load_nodes | int | (required) | Number of nodes reserved for background load | -## Development +### simulation_frequency_minutes -### Adding New Features +Determines the time window size for batching tasks. Larger windows mean fewer OpenDC invocations but less granular results. -**To modify windowing logic**: -1. Edit `sim_worker/window_manager.py` -2. Update `TimeWindow` or `WindowManager` classes -3. Add tests in `tests/test_window_manager.py` +### background_load_nodes -**To change OpenDC invocation**: -1. Edit `sim_worker/runner/opendc_runner.py` -2. Update input file generation or output parsing -3. Test with `tests/test_opendc_simple.py` +Simulates a certain number of nodes being used for background load. These nodes are subtracted from the first host type in the first cluster of the topology and are not available for assigning workload in the simulation. -**To add new operating mode**: -1. Add mode flag to `SimulationWorker.__init__` -2. Implement result handling in `_handle_results` +This is useful when the real datacenter has nodes running workloads that are not part of the simulated task trace (e.g., system services, other tenants). Set to `0` to use the full topology. -### Code Structure +## Logs ``` -sim_worker/ -โ”œโ”€โ”€ __init__.py -โ”œโ”€โ”€ main.py # Main worker + Kafka integration -โ”œโ”€โ”€ window_manager.py # Windowing logic -โ”œโ”€โ”€ result_cache.py # Caching mechanism -โ”œโ”€โ”€ experiment_manager.py # Experiment mode logic -โ””โ”€โ”€ runner/ - โ”œโ”€โ”€ __init__.py - โ”œโ”€โ”€ opendc_runner.py # OpenDC invocation - โ”œโ”€โ”€ models.py # Result models - โ””โ”€โ”€ java_home.py # Java detection +make logs-simulator ``` - -## Related Documentation - -- [Architecture Overview](../../docs/ARCHITECTURE.md) - System design -- [Data Models](../../docs/DATA_MODELS.md) - Input/output schemas -- [dc-mock Service](../dc-mock/README.md) - Data producer -- [dashboard Service](../dashboard/README.md) - Web dashboard and topology management - ---- - -For questions or contributions, see the [Contributing Guide](../../CONTRIBUTING.md). diff --git a/services/simulator/simulator/main.py b/services/simulator/simulator/main.py index 3f0a776..4758b7f 100644 --- a/services/simulator/simulator/main.py +++ b/services/simulator/simulator/main.py @@ -42,6 +42,7 @@ def __init__( speed_factor: float, run_output_dir: str, run_id: str, + background_load_nodes: int = 0, consumer_group: str = "simulators", ): """Initialize the simulation service. @@ -55,6 +56,7 @@ def __init__( speed_factor: Configured simulation speed multiplier run_output_dir: Base directory for run outputs run_id: Unique run ID for this session + background_load_nodes: Number of nodes reserved for background load consumer_group: Kafka consumer group ID """ self.kafka_bootstrap_servers = kafka_bootstrap_servers @@ -65,6 +67,7 @@ def __init__( self.simulation_frequency = timedelta(minutes=simulation_frequency_minutes) self.speed_factor = speed_factor self.run_id = run_id + self.background_load_nodes = background_load_nodes # Setup output directories - simulator writes to run_dir/simulator/ self.output_base_dir = Path(run_output_dir) / run_id / "simulator" @@ -121,6 +124,50 @@ def __init__( logger.info( f"Simulation frequency: {simulation_frequency_minutes} minutes (simulated time)" ) + logger.info(f"Background load nodes: {background_load_nodes}") + + def _reduce_topology_for_background_load(self, topology: Topology) -> Topology: + """Create a topology with reduced host count to simulate background load. + + Subtracts background_load_nodes from the first host type in the first cluster. + This simulates nodes being occupied by background workload and unavailable + for the simulation. + + Args: + topology: Original topology + + Returns: + New topology with reduced host count in first cluster + """ + if self.background_load_nodes == 0: + return topology + + # Deep copy to avoid modifying the original + reduced = copy.deepcopy(topology) + + if not reduced.clusters or not reduced.clusters[0].hosts: + logger.warning("Topology has no clusters or hosts, cannot reduce") + return topology + + first_host = reduced.clusters[0].hosts[0] + original_count = first_host.count + + if self.background_load_nodes >= original_count: + logger.warning( + f"background_load_nodes ({self.background_load_nodes}) >= " + f"available hosts ({original_count}) in first cluster, " + f"setting to {original_count - 1}" + ) + first_host.count = max(1, original_count - self.background_load_nodes) + else: + first_host.count = original_count - self.background_load_nodes + + logger.info( + f"Reduced topology: {original_count} -> {first_host.count} hosts " + f"in cluster '{reduced.clusters[0].name}' ({self.background_load_nodes} for background load)" + ) + + return reduced def _run_simulation(self) -> None: """Run OpenDC simulation with accumulated tasks. @@ -235,8 +282,8 @@ def _run_simulation(self) -> None: # Increment run number self.run_number += 1 - # Check if we can reuse cached results - topology_to_use = self.simulated_topology + # Apply background load reduction to topology + topology_to_use = self._reduce_topology_for_background_load(self.simulated_topology) # Create directories run_dir = self.output_base_dir / "opendc" / f"run_{self.run_number}" @@ -462,6 +509,7 @@ def main(): # Get simulator configuration simulation_frequency_minutes = config.services.simulator.simulation_frequency_minutes + background_load_nodes = config.services.simulator.background_load_nodes speed_factor = config.global_config.speed_factor run_output_dir = Path(os.getenv("DATA_DIR", "/app/data")) @@ -470,6 +518,7 @@ def main(): logger.info(f"Topology topic: {topology_topic}") logger.info(f"Simulated topology topic: {sim_topology_topic}") logger.info(f"Simulation frequency: {simulation_frequency_minutes} minutes") + logger.info(f"Background load nodes: {background_load_nodes}") logger.info(f"Speed factor: {speed_factor}x") logger.info(f"Data directory: {run_output_dir}") @@ -500,6 +549,7 @@ def main(): speed_factor=speed_factor, run_output_dir=str(run_output_dir), run_id=run_id, + background_load_nodes=background_load_nodes, consumer_group=consumer_group, ) service.run() diff --git a/services/simulator/simulator/result_processor.py b/services/simulator/simulator/result_processor.py index df3d333..b2c970b 100644 --- a/services/simulator/simulator/result_processor.py +++ b/services/simulator/simulator/result_processor.py @@ -127,6 +127,7 @@ def process_simulation_results( "power_draw", "energy_usage", "carbon_intensity", + "carbon_emission", "cached", ] diff --git a/site/community/0-support.mdx b/site/community/0-support.mdx deleted file mode 100644 index fb71245..0000000 --- a/site/community/0-support.mdx +++ /dev/null @@ -1,38 +0,0 @@ -# Support - -Need help? You can find out ways to talk to maintainers and community members below. - -
-
-
-

Browse the docs

-

Find what you're looking for in our documentation and guides.

- -
-
-

Join the community

-

Ask questions and find answers from other OpenDC users.

- -
-
-

Stay up to date

-

Find out what's new in OpenDC.

-
    -
  • Track the project on GitHub.
  • -
  • Follow AtLarge Research on Twitter.
  • -
-
-
-
- -## Need to contact the maintainers? - -Opening an issue or discussion on GitHub is always preferred so other community members can also contribute to and -benefit from the answers. However, if you need (private) contact with the maintainers, you may contact us -at ๐Ÿ“ง[opendc@atlarge-research.com](mailto:opendc@atlarge-research.com). diff --git a/site/community/1-team.mdx b/site/community/1-team.mdx deleted file mode 100644 index 719bf28..0000000 --- a/site/community/1-team.mdx +++ /dev/null @@ -1,13 +0,0 @@ -import TeamMembers from '@site/src/components/TeamMembers'; - -# Team - -The OpenDC project is currently developed and maintained by the [AtLarge Research Group](https://www.atlarge-research.com/) -from Amsterdam, the Netherlands. The team that works (or has worked) on OpenDC consists of: - - - -## Want to join our team? - -If you are interested in joining the OpenDC team, sent us a message at [opendc@atlarge-research.com](mailto:opendc@atlarge-research.com) -or visit the [AtLarge Research website](https://www.atlarge-research.com/new_students.html) for more information. diff --git a/site/community/2-contributing.md b/site/community/2-contributing.md deleted file mode 100644 index 36cd5cb..0000000 --- a/site/community/2-contributing.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: Contributing -hide_title: true -sidebar_label: Contributing ---- - -# Contributing to OpenDC - -First of all, thank you for wanting to contribute to OpenDC! -You can contribute in various meaningful ways: - -* Report a bug through [GitHub issues](https://github.com/fabianishere/atlarge-research/issues). -* Propose new functionality for using this project. -* Contribute improvements to the code and documentation. -* Provide feedback about how we can improve the project. -* Help answer questions on our [Discussions](https://github.com/atlarge-research/discussions) page. - -## Want to report a bug or suggest a feature? - -Please file an issue! First, have a look if someone has already filed an issue addressing your concern. If there already -is such an issue, feel free to comment on the issue to show your support for it, or to add additional information that -might be helpful. You can also just react with a thumbs-up ๐Ÿ‘ to the issue, to indicate that you'd be interested in its -resolution. This can help us prioritize what we spend our development time on. - -If you can't find an issue that fits your problem or feature request, open a new one. Describe actual and expected -behavior, and be as detailed as you can. We'll get back to you asap! - -## Want to contribute code? - -That's great! If you want to contribute to this -repository, [fork it](https://github.com/atlarge-research/opendc/new/master) and submit a pull request here when you're -ready! Be sure to describe *what* you changed and *why* you changed it, to help us understand what your contribution is -about. - -A quick note on commit messages: Please follow common Git standards when writing commit messages, -see [this post](https://cbea.ms/git-commit/) for details. diff --git a/site/community/3-research.md b/site/community/3-research.md deleted file mode 100644 index dc3a985..0000000 --- a/site/community/3-research.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: Research -hide_title: true -sidebar_label: Research ---- - -# Research with OpenDC - -On this page we list publications related to OpenDC or publications that have used OpenDC in their work. - -## Interested in working with us? - -If you are a new student and are interested in doing a project (e.g., bachelor thesis, master thesis) on OpenDC with us, -please visit [AtLarge Research website](https://www.atlarge-research.com/new_students.html) for more information. -For a sneak peek of the kind of work we do, have a look at the material listed below. - - -## Publications about OpenDC - -1. [OpenDC 2.0: Convenient Modeling and Simulation of Emerging Technologies in Cloud Datacenters](https://atlarge-research.com/pdfs/ccgrid21-opendc-paper.pdf) - **CCGrid 2021** - Fabian Mastenbroek, Georgios Andreadis, Soufiane Jounaid, Wenchen Lai, Jacob Burley, Jaro Bosch, Erwin van Eyk, - Laurens Versluis, Vincent van Beek, Alexandru Iosup -2. [The OpenDC vision: Towards collaborative datacenter simulation and exploration for everybody](https://atlarge-research.com/pdfs/opendc-vision17ispdc_cr.pdf) - **ISPDC 2017 (invited paper)** - Alexandru Iosup, Georgios Andreadis, Vincent van Beek, Matthijs Bijman, Erwin van Eyk, Mihai Neacsu, Leon Overweel, - Sacheendra Talluri, Laurens Versluis, Maaike Visser. - -## Publications using OpenDC - -1. [Capelin: Data-Driven Compute Capacity Procurement for Cloud Datacenters Using Portfolios of Scenarios](https://www.computer.org/csdl/journal/td/2022/01/09444213/1tYo2a8BeWA) - **IEEE Transactions on Parallel and Distributed Systems (TPDS), Jan. 2022** - Georgios Andreadis, Fabian Mastenbroek, Vincent van Beek, Alexandru Iosup -2. [A Reference Architecture for Datacenter Scheduling](https://arxiv.org/pdf/1808.04224) - **International Conference for High Performance Computing, Networking, Storage and Analysis 2018 (SC18)** - Georgios Andreadis, Laurens Versluis, Fabian Mastenbroek, Alexandru Iosup - -## Students using OpenDC -There have been also multiple students projects that have used OpenDC: - -1. [Radice: Data-driven Risk Analysis of Sustainable Cloud Infrastructure using Simulation](https://repository.tudelft.nl/islandora/object/uuid:00afeb36-724d-4edf-adc7-67ce991c7d12) - **Master Thesis, 2022** - Fabian Mastenbroek -2. [How Can Datacenters Join the Smart Grid to Address the Climate Crisis?](https://arxiv.org/abs/2108.01776) - **Bachelor Thesis, 2021** - Hongyu He -3. [Capelin: Fast Data-Driven Capacity Planning for Cloud Datacenters](https://repository.tudelft.nl/islandora/object/uuid:d6d50861-86a3-4dd3-a13f-42d84db7af66?collection=education) - **Master Thesis, 2020** - Georgios Andreadis -4. [Modeling and Simulation of the Google TensorFlow Ecosystem](https://atlarge-research.com/pdfs/lai2020thesis.pdf) - **Master Thesis, 2020** - Wenchen Lai -5. [OpenDC Serverless: Design, Implementation and Evaluation of a FaaS Platform Simulator](https://zenodo.org/record/4046675) - **Bachelor Thesis, 2020** - Soufiane Jounaid -6. [LEGO, but with Servers: Creating the Building Blocks to Design and Simulate Datacenters](https://atlarge-research.com/pdfs/BSc-Thesis-JACOB_BURLEY_FINAL.pdf) - **Bachelor Thesis, 2020** - Jacob Burley -7. [A Trace-Based Validation Study of OpenDC](https://atlarge-research.com/pdfs/2020-12-02_bsc_thesis_jaro_final.pdf) - **Bachelor Thesis, 2020** - Jaro Bosch -8. [A Systematic Design Space Exploration of Datacenter Schedulers](https://repository.tudelft.nl/islandora/object/uuid%3A20478016-cc7d-4c87-aa12-25b46f511277?collection=education) - **Bachelor Thesis, 2019** - Fabian Mastenbroek diff --git a/site/docs/concepts/_category_.json b/site/docs/concepts/_category_.json new file mode 100644 index 0000000..4d6cd70 --- /dev/null +++ b/site/docs/concepts/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Concepts", + "position": 3, + "link": { + "type": "generated-index", + "description": "Understand how OpenDT works." + } +} diff --git a/site/docs/concepts/architecture.md b/site/docs/concepts/architecture.md new file mode 100644 index 0000000..e168c52 --- /dev/null +++ b/site/docs/concepts/architecture.md @@ -0,0 +1,165 @@ +--- +sidebar_position: 5 +--- + +# Architecture + +OpenDT follows a modular architecture designed for datacenter digital twins. This page explains the high-level and detailed design. + +## High-Level Design + +![OpenDT High-Level Architecture](/img/design_opendt_hl.png) + +The architecture consists of five main components: + +### Physical Infrastructure (P) + +The **Datacenter Twin** represents the real physical infrastructure being monitored. In OpenDT, this is simulated by the **dc-mock** service, which replays historical workload and power data. + +### Front-end (B) + +The **Interfaces** component provides user access to the system. OpenDT implements this through: + +- **Grafana Dashboard** - Real-time visualization of power consumption and carbon emissions +- **REST API** - Programmatic access for querying data and controlling topology + +### Orchestration (C) + +The **Orchestrator** coordinates the flow of data between components. In OpenDT, **Kafka** serves as the message broker that enables this orchestration, with topics for workload, power, topology, and results. + +### Data Platform (D, E) + +- **Telemetry (D)** - Power consumption data from the physical infrastructure +- **Data Management (E)** - Storage and aggregation of simulation results in Parquet files + +### Simulator (F, G, H, I) + +The simulation engine is the core of OpenDT: + +- **Input (F)** - Tasks and topology configuration consumed from Kafka +- **Simulation Engine (G)** - OpenDC-based power prediction +- **Output (H)** - Aggregated results written to `agg_results.parquet` +- **Topology Adjuster (I)** - The **calibrator** service that optimizes topology parameters + +### Virtual Infrastructure (V) + +The **Digital Twin** maintains the virtual representation of the datacenter, including the current topology and calibrated parameters. + +## Detailed Design + +![OpenDT Detailed Architecture](/img/design_opendt_detailed.png) + +The detailed architecture expands on each component: + +### Users + +OpenDT supports multiple user types: + +- **Students** - Learning about datacenter simulation +- **Researchers** - Conducting experiments and validating models +- **Practitioners** - Operating and optimizing real datacenters + +### Frontend Components + +| Component | OpenDT Implementation | +|-----------|----------------------| +| Web Interface (D) | Grafana Dashboard | +| Command Line Interface (E) | `make` commands, CLI scripts | +| API Server (F) | FastAPI-based REST API | + +### Orchestration Layer + +| Component | OpenDT Implementation | +|-----------|----------------------| +| Physical Orchestrator (B) | dc-mock service | +| Central Orchestrator (A) | Kafka message broker | +| Digital Orchestrator (C) | simulator + calibrator services | + +### Data Platform + +| Component | OpenDT Implementation | +|-----------|----------------------| +| Observable Telemetry (K) | `dc.power` Kafka topic | +| Non-Observable Telemetry (L) | Derived metrics from simulation | +| Datalake (M) | Parquet files in `data/` directory | +| Physical & Digital Twin Metadata (N) | `topology.json`, `config.yaml` | +| Twinning Log (O) | `agg_results.parquet` with timestamps | + +### Simulator Components + +| Component | OpenDT Implementation | +|-----------|----------------------| +| Input (G) | Task accumulator, window manager | +| Simulation Engine (H) | OpenDC binary invocation | +| Output (I) | Result processor, Parquet writer | +| Topology Adjuster (J) | Calibration engine with grid search | + +### Infrastructure State + +**Physical Infrastructure (P1-P6)**: + +| State | Description | +|-------|-------------| +| System State (P1) | Current datacenter operational status | +| SLOs (P2) | Service level objectives | +| Configuration (P3) | Hardware configuration | +| Workload (P4) | Running tasks and jobs | +| Software Stack (P5) | Installed software | +| Topology (P6) | Physical hardware layout | + +**Virtual Infrastructure (V1-V6)**: + +The digital twin mirrors this state, with the ability to modify parameters for what-if analysis: + +| State | Description | +|-------|-------------| +| System State (V1) | Simulated operational status | +| SLOs (V2) | Target service levels | +| Configuration (V3) | Simulated configuration | +| Workload (V4) | Replayed workload | +| Software Stack (V5) | Simulated software | +| Topology (V6) | Adjustable topology with calibrated parameters | + +## Data Flow + +``` +Physical Infrastructure Digital Infrastructure + โ”‚ โ”‚ + โ–ผ โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ + โ”‚ dc-mock โ”‚ โ”€โ”€โ”€โ”€ dc.workload โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚ + โ”‚ โ”‚ โ”€โ”€โ”€โ”€ dc.power โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚ + โ”‚ โ”‚ โ”€โ”€โ”€โ”€ dc.topology โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ simulator โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ โ”‚ โ”‚ + โ–ผ โ–ผ โ–ผ + sim.results agg_results.parquet sim.topology + โ”‚ โ”‚ โ–ฒ + โ”‚ โ”‚ โ”‚ + โ”‚ โ–ผ โ”‚ + โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ + โ”‚ โ”‚ api โ”‚ โ”‚ + โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ”‚ โ”‚ โ”‚ + โ”‚ โ–ผ โ”‚ + โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚ Grafana โ”‚ โ”‚ calibrator โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Service Mapping + +| Architecture Component | OpenDT Service | Docker Container | +|------------------------|----------------|------------------| +| Physical Orchestrator | dc-mock | `dc-mock` | +| Central Orchestrator | Kafka | `kafka` | +| Simulation Engine | simulator | `simulator` | +| Topology Adjuster | calibrator | `calibrator` | +| API Server | api | `api` | +| Web Interface | Grafana | `grafana` | diff --git a/site/docs/concepts/calibration.md b/site/docs/concepts/calibration.md new file mode 100644 index 0000000..c69e6c8 --- /dev/null +++ b/site/docs/concepts/calibration.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 4 +--- + +# Calibration + +When enabled, the **calibrator** service optimizes topology parameters by comparing simulation output against actual power measurements. + +## Why Calibrate? + +Power models are approximations of real hardware behavior. Initial parameter values from datasheets may not accurately reflect actual power consumption under real workloads. + +Calibration automatically finds parameter values that minimize prediction error, improving accuracy over time. + +## Calibration Process + +``` + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Grid Search โ”‚ + โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ” โ”‚ + โ”‚ โ”‚Sim 1โ”‚ โ”‚Sim 2โ”‚ โ”‚Sim 3โ”‚ ... โ”‚ + โ”‚ โ”‚ P=A โ”‚ โ”‚ P=B โ”‚ โ”‚ P=C โ”‚ โ”‚ + โ”‚ โ””โ”€โ”€โ”ฌโ”€โ”€โ”˜ โ””โ”€โ”€โ”ฌโ”€โ”€โ”˜ โ””โ”€โ”€โ”ฌโ”€โ”€โ”˜ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ–ผ โ–ผ โ–ผ โ”‚ + โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ + โ”‚ โ”‚ Compare with Actual โ”‚ โ”‚ + โ”‚ โ”‚ Calculate MAPE โ”‚ โ”‚ + โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ”‚ โ”‚ โ”‚ + โ”‚ โ–ผ โ”‚ + โ”‚ Select Lowest Error โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ + Publish Calibrated Topology +``` + +1. **Accumulate**: Collect tasks and actual power readings +2. **Search**: Run parallel simulations with different parameter values +3. **Compare**: Calculate MAPE for each configuration +4. **Select**: Choose the parameter value with lowest error +5. **Publish**: Send calibrated topology to `sim.topology` +6. **Apply**: Simulator uses calibrated topology for future windows + +## MAPE Calculation + +Mean Absolute Percentage Error measures prediction accuracy: + +``` +MAPE = mean(|actual - predicted| / actual) ร— 100% +``` + +Lower MAPE indicates better prediction accuracy. A well-calibrated model typically achieves MAPE under 5%. + +## Calibrated Properties + +The calibrator can tune any numeric topology parameter. Common targets: + +| Property | Path | Description | +|----------|------|-------------| +| asymUtil | cpuPowerModel.asymUtil | Asymptotic utilization coefficient | +| calibrationFactor | cpuPowerModel.calibrationFactor | MSE model scaling factor | + +## Enabling Calibration + +Set `calibration_enabled: true` in your configuration file: + +```yaml +global: + calibration_enabled: true +``` + +The calibrator only runs when this flag is set. diff --git a/site/docs/concepts/data-models.md b/site/docs/concepts/data-models.md new file mode 100644 index 0000000..d59cc7d --- /dev/null +++ b/site/docs/concepts/data-models.md @@ -0,0 +1,92 @@ +--- +sidebar_position: 1 +--- + +# Data Models + +OpenDT uses Pydantic models to define the structure of all data flowing through the system. + +## Workload Data + +### Task + +A **Task** represents a job submitted to the datacenter. Each task requests compute resources for a specific duration. + +| Field | Type | Description | +|-------|------|-------------| +| id | int | Unique identifier | +| submission_time | datetime | When the task was submitted | +| duration | int | Total duration in milliseconds | +| cpu_count | int | Number of CPU cores requested | +| cpu_capacity | float | CPU speed in MHz | +| mem_capacity | int | Memory capacity in MB | +| fragments | list | Execution profile segments | + +A task represents a request for compute cycles: + +``` +Total Cycles = cpu_count ร— cpu_capacity ร— duration +``` + +### Fragment + +A **Fragment** describes resource usage during a segment of task execution. Tasks can have varying resource usage over time. + +| Field | Type | Description | +|-------|------|-------------| +| id | int | Fragment identifier | +| task_id | int | Parent task ID | +| duration | int | Segment duration in milliseconds | +| cpu_count | int | CPUs used in this segment | +| cpu_usage | float | CPU utilization value | + +## Power Data + +### Consumption + +A **Consumption** record represents actual power telemetry from the datacenter. + +| Field | Type | Description | +|-------|------|-------------| +| timestamp | datetime | Measurement time | +| power_draw | float | Instantaneous power in Watts | +| energy_usage | float | Accumulated energy in Joules | + +## Topology + +The **Topology** defines the datacenter hardware that the simulator uses to calculate power. It is hierarchical: Clusters contain Hosts, which have CPUs, Memory, and a Power Model. + +``` +Topology +โ””โ”€โ”€ Cluster (e.g., "C01") + โ””โ”€โ”€ Host + โ”œโ”€โ”€ count: 277 (number of identical hosts) + โ”œโ”€โ”€ CPU + โ”‚ โ”œโ”€โ”€ coreCount: 16 + โ”‚ โ””โ”€โ”€ coreSpeed: 2100 MHz + โ”œโ”€โ”€ Memory + โ”‚ โ””โ”€โ”€ memorySize: 128 GB + โ””โ”€โ”€ CPUPowerModel + โ”œโ”€โ”€ modelType: "mse" + โ”œโ”€โ”€ idlePower: 25 W + โ”œโ”€โ”€ maxPower: 174 W + โ””โ”€โ”€ calibrationFactor: 10.0 +``` + +## Model Definitions + +All data models are defined using Pydantic v2 in `libs/common/odt_common/models/`: + +| Model | File | Description | +|-------|------|-------------| +| Task | task.py | Workload task | +| Fragment | fragment.py | Task execution segment | +| Consumption | consumption.py | Power measurement | +| Topology | topology.py | Datacenter topology | +| WorkloadMessage | workload_message.py | Kafka message wrapper | + +These models provide: + +- Runtime type validation +- JSON serialization/deserialization +- Automatic API documentation diff --git a/site/docs/concepts/power-models.md b/site/docs/concepts/power-models.md new file mode 100644 index 0000000..0b7f521 --- /dev/null +++ b/site/docs/concepts/power-models.md @@ -0,0 +1,59 @@ +--- +sidebar_position: 2 +--- + +# Power Models + +The **CPUPowerModel** defines how CPU utilization translates to power consumption. Different model types suit different hardware characteristics. + +## Model Types + +| Model | Description | Best For | +|-------|-------------|----------| +| mse | Mean Squared Error based model | General purpose, default | +| asymptotic | Non-linear curve with asymptotic behavior | High-utilization workloads | +| linear | Linear interpolation between idle and max power | Simple approximations | + +## Parameters + +All power models share these common parameters: + +| Parameter | Type | Description | +|-----------|------|-------------| +| idlePower | float | Power draw at 0% utilization (Watts) | +| maxPower | float | Power draw at 100% utilization (Watts) | + +### MSE Model + +The MSE model uses a calibration factor to scale predictions: + +| Parameter | Description | +|-----------|-------------| +| calibrationFactor | Scaling multiplier for power calculation | + +### Asymptotic Model + +The asymptotic model provides non-linear scaling: + +| Parameter | Description | +|-----------|-------------| +| asymUtil | Curve coefficient (0-1), controls the shape of the utilization curve | + +## Example Configuration + +```json +{ + "cpuPowerModel": { + "modelType": "mse", + "idlePower": 25.0, + "maxPower": 174.0, + "calibrationFactor": 10.0 + } +} +``` + +## Calibration + +When calibration is enabled, OpenDT automatically tunes power model parameters to minimize the error between predicted and actual power consumption. + +The calibrator runs parallel simulations with different parameter values, calculates MAPE (Mean Absolute Percentage Error) against actual measurements, and publishes the best-performing configuration. diff --git a/site/docs/concepts/windowing.md b/site/docs/concepts/windowing.md new file mode 100644 index 0000000..46efb88 --- /dev/null +++ b/site/docs/concepts/windowing.md @@ -0,0 +1,52 @@ +--- +sidebar_position: 3 +--- + +# Windowing + +OpenDT aggregates tasks into **time windows** for batch simulation. This approach balances simulation accuracy with computational efficiency. + +## How Windows Work + +Tasks are assigned to windows based on their submission timestamp: + +``` +Window 0: [00:00 - 00:05) โ†’ Tasks with submission_time in this range +Window 1: [00:05 - 00:10) โ†’ ... +Window 2: [00:10 - 00:15) โ†’ ... +``` + +The window size is controlled by `simulation_frequency_minutes` in the configuration. + +## Window Lifecycle + +1. **Open**: Window accepts tasks as they arrive +2. **Fill**: Tasks accumulate in the window buffer +3. **Close**: Heartbeat timestamp exceeds window end time +4. **Simulate**: All accumulated tasks are processed by OpenDC +5. **Output**: Results are written to `agg_results.parquet` + +## Heartbeats + +**Heartbeat messages** are synthetic timestamps published by dc-mock to signal time progression. They enable deterministic window closing even when no tasks arrive during a period. + +Without heartbeats, windows would only close when new tasks arrive, leading to unpredictable timing in sparse workloads. + +## Cumulative Simulation + +OpenDT uses **cumulative simulation**: each window simulates all tasks from the beginning of the workload, not just tasks in that window. + +This approach ensures accurate power predictions because: + +- Long-running tasks affect power across multiple windows +- Scheduler state persists between windows +- Host allocations remain consistent + +## Tuning Window Size + +| Window Size | Trade-offs | +|-------------|------------| +| Small (1-5 min) | More granular results, higher computational cost | +| Large (30-60 min) | Fewer simulations, less granular results | + +For long experiments, larger windows reduce total runtime while still capturing overall trends. diff --git a/site/docs/configuration/_category_.json b/site/docs/configuration/_category_.json new file mode 100644 index 0000000..d444336 --- /dev/null +++ b/site/docs/configuration/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Configuration", + "position": 4, + "link": { + "type": "generated-index", + "description": "Configure OpenDT for your needs." + } +} diff --git a/site/docs/configuration/config-files.md b/site/docs/configuration/config-files.md new file mode 100644 index 0000000..21f44d1 --- /dev/null +++ b/site/docs/configuration/config-files.md @@ -0,0 +1,68 @@ +--- +sidebar_position: 1 +--- + +# Configuration Files + +OpenDT uses a single YAML configuration file that controls all services. + +## File Location + +Configuration files are stored in the `config/` directory: + +``` +config/ +โ”œโ”€โ”€ default.yaml # Default configuration +โ””โ”€โ”€ experiments/ + โ”œโ”€โ”€ experiment_1.yaml # Without calibration + โ””โ”€โ”€ experiment_2.yaml # With calibration +``` + +## Using a Configuration + +Specify a config file when starting OpenDT: + +```bash +make up config=config/experiments/experiment_1.yaml +``` + +If no config is specified, `config/default.yaml` is used. + +## File Structure + +```yaml +global: + speed_factor: 1 + calibration_enabled: false + +services: + dc-mock: + workload: "SURF" + heartbeat_frequency_minutes: 1 + simulator: + simulation_frequency_minutes: 5 + +kafka: + topics: + workload: + name: "dc.workload" + power: + name: "dc.power" + # ... +``` + +## Creating Custom Configurations + +1. Copy an existing configuration file +2. Modify settings as needed +3. Run with: `make up config=path/to/your/config.yaml` + +## Environment Variables + +Some settings can be overridden via environment variables: + +| Variable | Description | +|----------|-------------| +| CONFIG_FILE | Path to configuration file | +| RUN_ID | Current run identifier | +| KAFKA_BOOTSTRAP_SERVERS | Kafka broker address | diff --git a/site/docs/configuration/global-settings.md b/site/docs/configuration/global-settings.md new file mode 100644 index 0000000..7d357b0 --- /dev/null +++ b/site/docs/configuration/global-settings.md @@ -0,0 +1,54 @@ +--- +sidebar_position: 2 +--- + +# Global Settings + +The `global:` section contains settings that affect the entire system. + +## speed_factor + +Controls how fast the simulation runs relative to real time. + +| Value | Behavior | +|-------|----------| +| 1 | Real-time (1 second simulation = 1 second wall clock) | +| 300 | 300x speed (1 hour simulation = 12 seconds wall clock) | +| -1 | Maximum speed (no delays between messages) | + +```yaml +global: + speed_factor: 300 +``` + +### Estimating Runtime + +With the SURF workload (~7 days of data): + +| Speed Factor | Approximate Runtime | +|--------------|---------------------| +| 1 | 7 days | +| 60 | ~2.8 hours | +| 300 | ~34 minutes | +| -1 | ~10 minutes | + +## calibration_enabled + +Enables the calibrator service for automatic parameter tuning. + +```yaml +global: + calibration_enabled: true +``` + +When `true`: + +- The calibrator container starts +- Grid search runs periodically to optimize topology parameters +- Calibrated topologies are published to `sim.topology` +- Simulator uses calibrated values for predictions + +When `false`: + +- Calibrator does not start +- Simulator uses the original topology without modifications diff --git a/site/docs/configuration/topology.md b/site/docs/configuration/topology.md new file mode 100644 index 0000000..401a1af --- /dev/null +++ b/site/docs/configuration/topology.md @@ -0,0 +1,90 @@ +--- +sidebar_position: 4 +--- + +# Topology + +The topology file defines the datacenter hardware that OpenDT simulates. + +## File Location + +Each workload includes a `topology.json` file: + +``` +workload/SURF/topology.json +``` + +## Structure + +```json +{ + "clusters": [{ + "name": "C01", + "hosts": [{ + "name": "H01", + "count": 277, + "cpu": { + "coreCount": 16, + "coreSpeed": 2100 + }, + "memory": { + "memorySize": 128000000 + }, + "cpuPowerModel": { + "modelType": "mse", + "idlePower": 25.0, + "maxPower": 174.0, + "calibrationFactor": 10.0 + } + }], + "powerSource": { + "carbonTracePath": "/app/workload/carbon.parquet" + } + }] +} +``` + +## Cluster Configuration + +| Field | Description | +|-------|-------------| +| name | Cluster identifier | +| hosts | List of host configurations | +| powerSource | Carbon intensity data source | + +## Host Configuration + +| Field | Description | +|-------|-------------| +| name | Host identifier | +| count | Number of identical hosts | +| cpu | CPU configuration | +| memory | Memory configuration | +| cpuPowerModel | Power model parameters | + +## CPU Configuration + +| Field | Description | +|-------|-------------| +| coreCount | Number of CPU cores per host | +| coreSpeed | CPU clock speed in MHz | + +## Memory Configuration + +| Field | Description | +|-------|-------------| +| memorySize | Memory capacity in bytes | + +## Power Model Configuration + +| Field | Description | +|-------|-------------| +| modelType | Model type: "mse", "asymptotic", or "linear" | +| idlePower | Power at 0% utilization (Watts) | +| maxPower | Power at 100% utilization (Watts) | +| calibrationFactor | Scaling factor (mse model) | +| asymUtil | Curve coefficient (asymptotic model) | + +## Carbon Tracking + +The `powerSource.carbonTracePath` points to a Parquet file with grid carbon intensity data over time. This enables carbon emission calculations. diff --git a/site/docs/configuration/workloads.md b/site/docs/configuration/workloads.md new file mode 100644 index 0000000..dde6d9a --- /dev/null +++ b/site/docs/configuration/workloads.md @@ -0,0 +1,83 @@ +--- +sidebar_position: 3 +--- + +# Workloads + +Workload data defines the tasks and power measurements that OpenDT simulates. + +## Workload Directory + +Workloads are stored in the `workload/` directory. Each workload has its own subdirectory: + +``` +workload/ +โ””โ”€โ”€ SURF/ + โ”œโ”€โ”€ tasks.parquet + โ”œโ”€โ”€ fragments.parquet + โ”œโ”€โ”€ consumption.parquet + โ”œโ”€โ”€ carbon.parquet + โ””โ”€โ”€ topology.json +``` + +## Required Files + +| File | Description | +|------|-------------| +| tasks.parquet | Task definitions with submission times and resource requirements | +| fragments.parquet | Task execution profiles showing resource usage over time | +| consumption.parquet | Actual power measurements from the real datacenter | +| carbon.parquet | Grid carbon intensity data | +| topology.json | Datacenter hardware configuration | + +## Selecting a Workload + +Set the workload in your configuration file: + +```yaml +services: + dc-mock: + workload: "SURF" +``` + +The value is the directory name under `workload/`. + +## Creating Custom Workloads + +To add a new workload: + +1. Create a directory under `workload/` +2. Add the required Parquet files +3. Create a `topology.json` matching your datacenter hardware +4. Reference the directory name in your configuration + +## Data Format + +### tasks.parquet + +| Column | Type | Description | +|--------|------|-------------| +| id | int | Unique task identifier | +| submission_time | timestamp | When the task was submitted | +| duration | int | Task duration in milliseconds | +| cpu_count | int | Number of CPU cores | +| cpu_capacity | float | CPU speed in MHz | +| mem_capacity | int | Memory in MB | + +### fragments.parquet + +| Column | Type | Description | +|--------|------|-------------| +| id | int | Fragment identifier | +| task_id | int | Parent task ID | +| duration | int | Fragment duration in milliseconds | +| cpu_count | int | CPUs used | +| cpu_usage | float | CPU utilization | + +### consumption.parquet + +| Column | Type | Description | +|--------|------|-------------| +| timestamp | timestamp | Measurement time | +| power_draw | float | Power in Watts | +| energy_usage | float | Energy in Joules | diff --git a/site/docs/documentation/Input/AllocationPolicy.md b/site/docs/documentation/Input/AllocationPolicy.md deleted file mode 100644 index 96aacc9..0000000 --- a/site/docs/documentation/Input/AllocationPolicy.md +++ /dev/null @@ -1,265 +0,0 @@ -Allocation policies define how, when and where a task is executed. - -There are two types of allocation policies: -1. **[Filter](#filter-policy)** - The basic allocation policy that selects a host for each task based on filters and weighters -2. **[TimeShift](#timeshift-policy)** - Extends the Filter scheduler allowing tasks to be delayed to better align with the availability of low-carbon power. - -In the following section we discuss the different allocation policies, and how to define them in an Experiment file. - -## Filter policy -To use a filter scheduler, the user has to set the type of the policy to "filter". -A filter policy requires a list of filters and weighters which characterize the policy. - -A filter policy consists of two main components: -1. **[Filters](#filters)** - Filters select all hosts that are eligible to execute the given task. -2. **[Weighters](#weighters)** - Weighters are used to rank the eligible hosts. The host with the highest weight is selected to execute the task. - -:::info Code -All code related to reading Allocation policies can be found [here](https://github.com/atlarge-research/opendc/blob/master/opendc-experiments/opendc-experiments-base/src/main/kotlin/org/opendc/experiments/base/experiment/specs/allocation/AllocationPolicySpec.kt) -::: - -### Filters -Filters select all hosts that are eligible to execute the given task. -Filters are defined as JSON objects in the experiment file. - -The user defines which filter to use by setting the "type". -OpenDC currently supports the following 7 filters: - -#### ComputeFilter -Returns host if it is running. -Does not require any more parameters. - -```json -{ - "type": "Compute" -} -``` - -#### SameHostHostFilter -Ensures that after failure, a task is executed on the same host again. -Does not require any more parameters. - -```json -{ - "type": "DifferentHost" -} -``` - -#### DifferentHostFilter -Ensures that after failure, a task is *not* executed on the same host again. -Does not require any more parameters. - -```json -{ - "type": "DifferentHost" -} -``` - -#### InstanceCountHostFilter -Returns host if the number of instances running on the host is less than the maximum number of instances allowed. -The User needs to provide the maximum number of instances that can be run on a host. -```json -{ - "type": "InstanceCount", - "limit": 1 -} -``` - -#### RamHostFilter -Returns hosts if the amount of RAM available on the host is greater than the amount of RAM required by the task. -The user can provide an allocationRatio which is multiplied with the amount of RAM available on the host. -This can be used to allow for over subscription. -```json -{ - "type": "Ram", - "allocationRatio": 2.5 -} -``` - -#### VCpuCapacityHostFilter -Returns hosts if CPU capacity available on the host is greater than the CPU capacity required by the task. - -```json -{ - "type": "VCpuCapacity" -} -``` - -#### VCpuHostFilter -Returns host if the number of cores available on the host is greater than the number of cores required by the task. -The user can provide an allocationRatio which is multiplied with the amount of RAM available on the host. -This can be used to allow for over subscription. - -```json -{ - "type": "VCpu", - "allocationRatio": 2.5 -} -``` - -:::info Code -All code related to reading Filters can be found [here](https://github.com/atlarge-research/opendc/blob/master/opendc-experiments/opendc-experiments-base/src/main/kotlin/org/opendc/experiments/base/experiment/specs/allocation/HostFilterSpec.kt) -::: - -### Weighters -Weighters are used to rank the eligible hosts. The host with the highest weight is selected to execute the task. -Weighters are defined as JSON objects in the experiment file. - -The user defines which filter to use by setting the "type". -The user can also provide a multiplying that is multiplied with the weight of the host. -This can be used to increase or decrease the importance of the host. -Negative multipliers are also allowed, and can be used to invert the ranking of the host. -OpenDC currently supports the following 5 weighters: - -#### RamWeigherSpec -Order the hosts by the amount of RAM available on the host. - -```json -{ - "type": "Ram", - "multiplier": 2.0 -} -``` - -#### CoreRamWeighter -Order the hosts by the amount of RAM available per core on the host. - -```json -{ - "type": "CoreRam", - "multiplier": 0.5 -} -``` - -#### InstanceCountWeigherSpec -Order the hosts by the number of instances running on the host. - -```json -{ - "type": "InstanceCount", - "multiplier": -1.0 -} -``` - -#### VCpuCapacityWeigherSpec -Order the hosts by the capacity per core on the host. - -```json -{ - "type": "VCpuCapacity", - "multiplier": 0.5 -} -``` - -#### VCpuWeigherSpec -Order the hosts by the number of cores available on the host. - -```json -{ - "type": "VCpu", - "multiplier": 2.5 -} -``` - -:::info Code -All code related to reading Weighters can be found [here](https://github.com/atlarge-research/opendc/blob/master/opendc-experiments/opendc-experiments-base/src/main/kotlin/org/opendc/experiments/base/experiment/specs/allocation/HostWeigherSpec.kt) -::: - -### Examples -Following is an example of a Filter policy: -```json -{ - "type": "filter", - "filters": [ - { - "type": "Compute" - }, - { - "type": "VCpu", - "allocationRatio": 1.0 - }, - { - "type": "Ram", - "allocationRatio": 1.5 - } - ], - "weighers": [ - { - "type": "Ram", - "multiplier": 1.0 - } - ] -} -``` - -## TimeShift policy -Timeshift extends the Filter policy by allowing tasks to be delayed to better align with the availability of low-carbon power. -A user can define a timeshift policy by setting the type to "timeshift". - -task is scheduled when the current carbon intensity is below the carbon threshold. Otherwise, they are delayed. The -carbon threshold is determined by taking the 35 percentile of next weekโ€™s carbon forecast. When used, tasks can be interrupted -when the carbon intensity exceeds the threshold during execution. All tasks have a maximum delay time defined in the workload. When the maximum delay is reached, -tasks cannot be delayed anymore. - - -Similar to the filter policy, the user can define a list of filters and weighters. -However, in addittion, the user can provide parameters that influence how tasks are delayed: - -| Variable | Type | Required? | Default | Description | -|------------------------|-----------------------------|-----------|-----------------|-----------------------------------------------------------------------------------| -| filters | List[Filter] | no | [ComputeFilter] | Filters used to select eligible hosts. | -| weighters | List[Weighter] | no | [] | Weighters used to rank hosts. | -| windowSize | integer | no | 168 | How far back does the scheduler look to determine the Carbon Intensity threshold? | -| forecast | boolean | no | true | Does the the policy use carbon forecasts? | -| shortForecastThreshold | double | no | 0.2 | Threshold is used for short tasks (<2hours) | -| longForecastThreshold | double | no | 0.35 | Threshold is used for long tasks (>2hours) | -| forecastSize | integer | no | 24 | The number of hours of forecasts that is taken into account | -| taskStopper | [TaskStopper](#taskstopper) | no | null | Policy for interrupting tasks. If not provided, tasks are never interrupted | - -### TaskStopper - -Aside from delaying tasks, users might want to interrupt tasks that are running. -For example, if a tasks is running when only high-carbon energy is available, the task can be interrupted and rescheduled to a later time. - -A TaskStopper is defined as a JSON object in the Timeshift policy. -A TasksStopper consists of the following components: - -| Variable | Type | Required? | Default | Description | -|-----------------------|-----------------------------|-----------|---------|-----------------------------------------------------------------------------------| -| windowSize | integer | no | 168 | How far back does the scheduler look to determine the Carbon Intensity threshold? | -| forecast | boolean | no | true | Does the the policy use carbon forecasts? | -| forecastThreshold | double | no | 0.6 | Threshold is used for short tasks (<2hours) | -| forecastSize | integer | no | 24 | The number of hours of forecasts that is taken into account | - - -## Prefabs -Aside from custom policies, OpenDC also provides a set of pre-defined policies that can be used. -A prefab can be defined by setting the type to "prefab" and providing the name of the prefab. - -Example: -```json -{ - "type": "prefab", - "policyName": "Mem" -} -``` - -The following prefabs are available: - -| Name | Filters | Weighters | Timeshifting | -|---------------------|----------------------------------------------|----------------------------|--------------| -| Mem | ComputeFilter
VCpuFilter
RamFilter | RamWeigher(1.0) | No | -| MemInv | ComputeFilter
VCpuFilter
RamFilter | RamWeigher(-1.0) | No | -| CoreMem | ComputeFilter
VCpuFilter
RamFilter | CoreRamWeigher(1.0) | No | -| CoreMemInv | ComputeFilter
VCpuFilter
RamFilter | CoreRamWeigher(-1.0) | No | -| ActiveServers | ComputeFilter
VCpuFilter
RamFilter | InstanceCountWeigher(1.0) | No | -| ActiveServersInv | ComputeFilter
VCpuFilter
RamFilter | InstanceCountWeigher(-1.0) | No | -| ProvisionedCores | ComputeFilter
VCpuFilter
RamFilter | VCpuWeigher(1.0) | No | -| ProvisionedCoresInv | ComputeFilter
VCpuFilter
RamFilter | VCpuWeigher(-1.0) | No | -| Random | ComputeFilter
VCpuFilter
RamFilter | [] | No | -| TimeShift | ComputeFilter
VCpuFilter
RamFilter | RamWeigher(1.0) | Yes | - -:::info Code -All code related to prefab schedulers can be found [here](https://github.com/atlarge-research/opendc/blob/master/opendc-compute/opendc-compute-simulator/src/main/kotlin/org/opendc/compute/simulator/scheduler/ComputeSchedulers.kt) -::: - diff --git a/site/docs/documentation/Input/CheckpointModel.md b/site/docs/documentation/Input/CheckpointModel.md deleted file mode 100644 index 7c622ea..0000000 --- a/site/docs/documentation/Input/CheckpointModel.md +++ /dev/null @@ -1,25 +0,0 @@ -Checkpointing is a technique to reduce the impact of machine failure. -When using Checkpointing, tasks make periodical snapshots of their state. -If a task fails, it can be restarted from the last snapshot instead of starting from the beginning. - -A user can define a checkpoint model using the following parameters: - -| Variable | Type | Required? | Default | Description | -|---------------------------|--------|-----------|---------|----------------------------------------------------------------------------------------------------------------------| -| checkpointInterval | Int64 | no | 3600000 | The time between checkpoints in ms | -| checkpointDuration | Int64 | no | 300000 | The time to create a snapshot in ms | -| checkpointIntervalScaling | Double | no | 1.0 | The scaling of the checkpointInterval after each successful checkpoint. The default of 1.0 means no scaling happens. | - -### Example - -```json -{ - "checkpointInterval": 3600000, - "checkpointDuration": 300000, - "checkpointIntervalScaling": 1.5 -} -``` - -In this example, a snapshot is created every hour, and the snapshot creation takes 5 minutes. -The checkpointIntervalScaling is set to 1.5, which means that after each successful checkpoint, -the interval between checkpoints will be increased by 50% (for example from 1 to 1.5 hours). diff --git a/site/docs/documentation/Input/Experiment.md b/site/docs/documentation/Input/Experiment.md deleted file mode 100644 index 8d3462a..0000000 --- a/site/docs/documentation/Input/Experiment.md +++ /dev/null @@ -1,107 +0,0 @@ -When using OpenDC, an experiment defines what should be run, and how. An experiment consists of one or more scenarios, -each defining a different simulation to run. Scenarios can differ in many things, such as the topology that is used, -the workload that is run, or the policies that are used to name a few. An experiment is defined using a JSON file. -In this page, we will discuss how to properly define experiments for OpenDC. - -:::info Code -All code related to reading and processing Experiment files can be found [here](https://github.com/atlarge-research/opendc/tree/master/opendc-experiments/opendc-experiments-base/src/main/kotlin/org/opendc/experiments/base/experiment) -The code used to run experiments can be found [here](https://github.com/atlarge-research/opendc/tree/master/opendc-experiments/opendc-experiments-base/src/main/kotlin/org/opendc/experiments/base/runner) -::: - -## Schema - -In the following section, we describe the different components of an experiment. Following is a table with all experiment components: - -| Variable | Type | Required? | Default | Description | -|--------------------|----------------------------------------------------------------------|-----------|---------------|-------------------------------------------------------------------------------------------------------| -| name | string | no | "" | Name of the scenario, used for identification and referencing. | -| outputFolder | string | no | "output" | Directory where the simulation outputs will be stored. | -| runs | integer | no | 1 | Number of times the same scenario should be run. Each scenario is run with a different seed. | -| initialSeed | integer | no | 0 | The seed used for random number generation during a scenario. Setting a seed ensures reproducability. | -| topologies | List[path/to/file] | yes | N/A | Paths to the JSON files defining the topologies. | -| workloads | List[[Workload](/docs/documentation/Input/Workload)] | yes | N/A | Paths to the files defining the workloads executed. | -| allocationPolicies | List[[AllocationPolicy](/docs/documentation/Input/AllocationPolicy)] | yes | N/A | Allocation policies used for resource management in the scenario. | -| failureModels | List[[FailureModel](/docs/documentation/Input/FailureModel)] | no | List[null] | List of failure models to simulate various types of failures. | -| maxNumFailures | List[integer] | no | [10] | The max number of times a task can fail before being terminated. | -| checkpointModels | List[[CheckpointModel](/docs/documentation/Input/CheckpointModel)] | no | List[null] | Paths to carbon footprint trace files. | -| exportModels | List[[ExportModel](/docs/documentation/Input/ExportModel)] | no | List[default] | Specifications for exporting data from the simulation. | - -Most components of an experiment are not single values, but lists of values. -This allows users to run multiple scenarios using a single experiment file. -OpenDC will generate and execute all permutations of the different values. - -Some of the components in an experiment file are paths to files, or complicated objects. The format of these components -are defined in their respective pages. - -## Examples -In the following section, we discuss several examples of experiment files. - -### Simple - -The simplest experiment that can be provided to OpenDC is shown below: -```json -{ - "topologies": [ - { - "pathToFile": "topologies/topology1.json" - } - ], - "workloads": [ - { - "type": "ComputeWorkload", - "pathToFile": "traces/bitbrains-small" - } - ], - "allocationPolicies": [ - { - "type": "prefab", - "policyName": "Mem" - } - ] -} -``` - -This experiment creates a simulation from file topology1, located in the topologies folder, with a workload trace from the -bitbrains-small file, and an allocation policy of type Mem. The simulation is run once (by default), and the default -name is "". - -### Complex -Following is an example of a more complex experiment: -```json -{ - "topologies": [ - { - "pathToFile": "topologies/topology1.json" - }, - { - "pathToFile": "topologies/topology2.json" - }, - { - "pathToFile": "topologies/topology3.json" - } - ], - "workloads": [ - { - "pathToFile": "traces/bitbrains-small", - "type": "ComputeWorkload" - }, - { - "pathToFile": "traces/bitbrains-large", - "type": "ComputeWorkload" - } - ], - "allocationPolicies": [ - { - "type": "prefab", - "policyName": "Mem" - }, - { - "type": "prefab", - "policyName": "Mem-Inv" - } - ] -} -``` - -This scenario runs a total of 12 experiments. We have 3 topologies (3 datacenter configurations), each simulated with -2 distinct workloads, each using a different allocation policy (either Mem or Mem-Inv). diff --git a/site/docs/documentation/Input/ExportModel.md b/site/docs/documentation/Input/ExportModel.md deleted file mode 100644 index 12e7eba..0000000 --- a/site/docs/documentation/Input/ExportModel.md +++ /dev/null @@ -1,50 +0,0 @@ -During simulation, OpenDC exports data to files (see [Output](/docs/documentation/Output.md)). -The user can define what and how data is exported using the `exportModels` parameter in the experiment file. - -## ExportModel - - - -| Variable | Type | Required? | Default | Description | -|---------------------|-----------------------------------------|-----------|-----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------| -| exportInterval | Int64 | no | 300 | The duration between two exports in seconds | -| filesToExport | Int64 | no | 24 | How often OpenDC prints an update during simulation. | | -| computeExportConfig | [ComputeExportConfig](#checkpointmodel) | no | Default | The features that should be exported during the simulation | -| filesToExport | List[string] | no | all files | List of the files that should be exported during simulation. The elements should be picked from the set ("host", "task", "powerSource", "battery", "service") | - - - -### ComputeExportConfig -The ComputeExportConfig defines which features should be exported during the simulation. -Several features will always be exported, regardless of the configuration. -When not provided, all features are exported. - - -| Variable | Type | Required? | Base | Default | Description | -|--------------------------|--------------|-----------|------------------------------------------------------------------------|--------------|-----------------------------------------------------------------------| -| hostExportColumns | List[String] | no | name
cluster_name
timestamp
timestamp_absolute
| All features | The features that should be exported to the host output file. | -| taskExportColumns | List[String] | no | task_id
task_name
timestamp
timestamp_absolute
| All features | The features that should be exported to the task output file. | -| powerSourceExportColumns | List[String] | no | name
cluster_name
timestamp
timestamp_absolute
| All features | The features that should be exported to the power source output file. | -| batteryExportColumns | List[String] | no | name
cluster_name
timestamp
timestamp_absolute
| All features | The features that should be exported to the battery output file. | -| serviceExportColumns | List[String] | no | timestamp
timestamp_absolute
| All features | The features that should be exported to the service output file. | - -### Example - -```json -{ - "exportInterval": 3600, - "printFrequency": 168, - "filesToExport": ["host", "task", "service"], - "computeExportConfig": { - "hostExportColumns": ["power_draw", "energy_usage", "cpu_usage", "cpu_utilization"], - "taskExportColumns": ["submission_time", "schedule_time", "finish_time", "task_state"], - "serviceExportColumns": ["tasks_total", "tasks_pending", "tasks_active", "tasks_completed", "tasks_terminated", "hosts_up"] - } -} -``` -In this example: -- the simulation will export data every hour (3600 seconds). -- The simulation will print an update every 168 seconds. -- Only the host, task and service files will be exported. -- Only a selection of features are exported for each file. - diff --git a/site/docs/documentation/Input/FailureModel.md b/site/docs/documentation/Input/FailureModel.md deleted file mode 100644 index 714d215..0000000 --- a/site/docs/documentation/Input/FailureModel.md +++ /dev/null @@ -1,224 +0,0 @@ -### FailureModel -The failure model that should be used during the simulation -See [FailureModels](FailureModel.md) for detailed instructions. - - - -OpenDC provides three types of failure models: [Trace-based](#trace-based-failure-models), [Sample-based](#sample-based-failure-models), -and [Prefab](#prefab-failure-models). - -All failure models have a similar structure containing three simple steps. - -1. The _interval_ time determines the time between two failures. -2. The _duration_ time determines how long a single failure takes. -3. The _intensity_ determines how many hosts are effected by a failure. - -:::info Code -The code that defines the Failure Models can found [here](https://github.com/atlarge-research/opendc/blob/master/opendc-experiments/opendc-experiments-base/src/main/kotlin/org/opendc/experiments/base/experiment/specs/FailureModelSpec.kt). -::: - -## Trace based failure models -Trace-based failure models are defined by a parquet file. This file defines the interval, duration, and intensity of -several failures. The failures defined in the file are looped. A valid failure model file follows the format defined below: - -| Metric | Datatype | Unit | Summary | -|-------------------|------------|---------------|--------------------------------------------| -| failure_interval | int64 | milli seconds | The duration since the last failure | -| failure_duration | int64 | milli seconds | The duration of the failure | -| failure_intensity | float64 | ratio | The ratio of hosts effected by the failure | - -:::info Code -The code implementation of Trace Based Failure Models can be found [here](https://github.com/atlarge-research/opendc/blob/master/opendc-compute/opendc-compute-failure/src/main/kotlin/org/opendc/compute/failure/models/TraceBasedFailureModel.kt) -::: - -### Example -A trace-based failure model is specified by setting "type" to "trace-based". -After, the user can define the path to the failure trace using "pathToFile": -```json -{ - "type": "trace-based", - "pathToFile": "path/to/your/failure_trace.parquet" -} -``` - -The "repeat" value can be set to false if the user does not want the failures to loop: -```json -{ - "type": "trace-based", - "pathToFile": "path/to/your/failure_trace.parquet", - "repeat": "false" -} -``` - -## Sample based failure models -Sample based failure models sample from three distributions to get the _interval_, _duration_, and _intensity_ of -each failure. Sample-based failure models are effected by randomness and will thus create different results based -on the provided seed. - -:::info Code -The code implementation for the Sample based failure models can be found [here](https://github.com/atlarge-research/opendc/blob/master/opendc-compute/opendc-compute-failure/src/main/kotlin/org/opendc/compute/failure/models/SampleBasedFailureModel.kt) -::: - -### Distributions -OpenDC supports eight different distributions based on java's [RealDistributions](https://commons.apache.org/proper/commons-math/javadocs/api-3.6.1/org/apache/commons/math3/distribution/RealDistribution.html). -Because the different distributions require different variables, they have to be specified with a specific "type". -Next, we show an example of a correct specification of all available distributions in OpenDC. - -#### [ConstantRealDistribution](https://commons.apache.org/proper/commons-math/javadocs/api-3.6.1/org/apache/commons/math3/distribution/ConstantRealDistribution.html) - -```json -{ - "type": "constant", - "value": 10.0 -} -``` - -#### [ExponentialDistribution](https://commons.apache.org/proper/commons-math/javadocs/api-3.6.1/org/apache/commons/math3/distribution/ExponentialDistribution.html) -```json -{ - "type": "exponential", - "mean": 1.5 -} -``` - -#### [GammaDistribution](https://commons.apache.org/proper/commons-math/javadocs/api-3.6.1/org/apache/commons/math3/distribution/GammaDistribution.html) -```json -{ - "type": "gamma", - "shape": 1.0, - "scale": 0.5 -} -``` - -#### [LogNormalDistribution](https://commons.apache.org/proper/commons-math/javadocs/api-3.6.1/org/apache/commons/math3/distribution/LogNormalDistribution.html) -```json -{ - "type": "log-normal", - "scale": 1.0, - "shape": 0.5 -} -``` - -#### [NormalDistribution](https://commons.apache.org/proper/commons-math/javadocs/api-3.6.1/org/apache/commons/math3/distribution/NormalDistribution.html) -```json -{ - "type": "normal", - "mean": 1.0, - "std": 0.5 -} -``` - -#### [ParetoDistribution](https://commons.apache.org/proper/commons-math/javadocs/api-3.6.1/org/apache/commons/math3/distribution/ParetoDistribution.html) -```json -{ - "type": "pareto", - "scale": 1.0, - "shape": 0.6 -} -``` - -#### [UniformRealDistribution](https://commons.apache.org/proper/commons-math/javadocs/api-3.6.1/org/apache/commons/math3/distribution/UniformRealDistribution.html) -```json -{ - "type": "constant", - "lower": 5.0, - "upper": 10.0 -} -``` - -#### [WeibullDistribution](https://commons.apache.org/proper/commons-math/javadocs/api-3.6.1/org/apache/commons/math3/distribution/WeibullDistribution.html) -```json -{ - "type": "constant", - "alpha": 0.5, - "beta": 1.2 -} -``` - -### Example -A sample-based failure model is defined using three distributions for _intensity_, _duration_, and _intensity_. -Distributions can be mixed however the user wants. Note, values for _intensity_ and _duration_ are clamped to be positive. -The _intensity_ is clamped to the range [0.0, 1.0). -To specify a sample-based failure model, the type needs to be set to "custom". - -Example: -```json -{ - "type": "custom", - "iatSampler": { - "type": "exponential", - "mean": 1.5 - }, - "durationSampler": { - "type": "constant", - "alpha": 0.5, - "beta": 1.2 - }, - "nohSampler": { - "type": "constant", - "value": 0.5 - } -} -``` - -## Prefab failure models -The final type of failure models is the prefab models. These are models that are predefined in OpenDC and are based on -research. Currently, OpenDC has 9 prefab models based on [The Failure Trace Archive: Enabling the comparison of failure measurements and models of distributed systems](https://www-sciencedirect-com.vu-nl.idm.oclc.org/science/article/pii/S0743731513000634) -The figure below shows the values used to define the failure models. -![failureModels.png](../../../static/img/failureModels.png) - -Each failure model is defined four times, on for each of the four distribution. -The final list of available prefabs is thus: - - G5k06Exp - G5k06Wbl - G5k06LogN - G5k06Gam - Lanl05Exp - Lanl05Wbl - Lanl05LogN - Lanl05Gam - Ldns04Exp - Ldns04Wbl - Ldns04LogN - Ldns04Gam - Microsoft99Exp - Microsoft99Wbl - Microsoft99LogN - Microsoft99Gam - Nd07cpuExp - Nd07cpuWbl - Nd07cpuLogN - Nd07cpuGam - Overnet03Exp - Overnet03Wbl - Overnet03LogN - Overnet03Gam - Pl05Exp - Pl05Wbl - Pl05LogN - Pl05Gam - Skype06Exp - Skype06Wbl - Skype06LogN - Skype06Gam - Websites02Exp - Websites02Wbl - Websites02LogN - Websites02Gam - -:::info Code -The different Prefab models can be found [here](https://github.com/atlarge-research/opendc/tree/master/opendc-compute/opendc-compute-failure/src/main/kotlin/org/opendc/compute/failure/prefab) -::: - -### Example -To specify a prefab model, the "type" needs to be set to "prefab". -After, the prefab can be defined with "prefabName": - -```json -{ - "type": "prefab", - "prefabName": "G5k06Exp" -} -``` - diff --git a/site/docs/documentation/Input/Topology/Battery.md b/site/docs/documentation/Input/Topology/Battery.md deleted file mode 100644 index 7049269..0000000 --- a/site/docs/documentation/Input/Topology/Battery.md +++ /dev/null @@ -1,37 +0,0 @@ -Batteries can be used to store energy for later use. -In previous work, we have used batteries to store energy from the grid when the carbon intensity is low, -and use this energy when the carbon intensity is high. - -Batteries are defined using the following parameters: - -| variable | type | Unit | required? | default | description | -|------------------|---------------------------|-------|-----------|---------|-----------------------------------------------------------------------------------| -| name | string | N/A | no | Battery | The name of the battery. This is only important for debugging and post-processing | -| capacity | Double | kWh | yes | N/A | The total amount of energy that the battery can hold. | -| chargingSpeed | Double | W | yes | N/A | Charging speed of the battery. | -| initialCharge | Double | kWh | no | 0.0 | The initial charge of the battery. If not given, the battery starts empty. | -| batteryPolicy | [Policy](#battery-policy) | N/A | yes | N/A | The policy which decides when to charge and discharge. | -| embodiedCarbon | Double | gram | no | 0.0 | The embodied carbon emitted while creating this battery. | -| expectedLifetime | Double | Years | yes | 0.0 | The expected lifetime of the battery. | - -## Battery Policy -To determine when to charge and discharge the battery, a policy is required. -Currently, all policies for batteries are based on the carbon intensity of the grid. - -The best performing policy is called "runningMeanPlus" and is based on the running mean of the carbon intensity. -it can be defined with the following JSON: - -```json -{ - "type": "runningMeanPlus", - "startingThreshold": 123.2, - "windowSize": 168 -} -``` - -In which `startingThreshold` is the initial carbon threshold used. -`windowSize` is the size of the window used to calculate the running mean. - -:::info Alert -This page with be extended with more text and policies in the future. -::: diff --git a/site/docs/documentation/Input/Topology/DistributionPolicy.md b/site/docs/documentation/Input/Topology/DistributionPolicy.md deleted file mode 100644 index a2859dc..0000000 --- a/site/docs/documentation/Input/Topology/DistributionPolicy.md +++ /dev/null @@ -1,83 +0,0 @@ -The Distribution Policy is used to determine how resources are distributed at a host, across multiple Virtual Machines (VMs). -OpenDC supports multiple distribution policies, describe as below, each extending a FlowDistributor class. - - -## Best Effort Distribution Policy - -The BestEffortFlowDistributor implements a sophisticated time-sliced round-robin approach designed to maximize resource utilization while maintaining reasonable fairness over time. -The distributor uses a configurable time interval to advance its round-robin index, ensuring that allocation priority rotates among consumers. -It prioritizes already active suppliers to reduce activation overhead and implements a two-phase allocation strategy: first satisfying demands in round-robin order, then optimizing remaining capacity distribution. -The update interval length parameter defines how long each round in the round-robin cycle lasts. -This policy is heavily inspired by the best effort GPU scheduling policy used in NVIDIA's vGPU scheduler. - -Example -``` json -"gpuDistributionPolicy": { - "type": "BEST_EFFORT", - "updateIntervalLength": 60000 -} -``` - -## Equal Share Distribution Policy - -The EqualShareFlowDistributor implements the simplest distribution strategy by dividing total capacity equally among all consumers, -completely ignoring individual demand levels. This distributor calculates a fixed equal share (totalSupply / numberOfConsumers) and allocates this amount to every consumer regardless of their actual needs. -The algorithm is deterministic and predictable, making it ideal for scenarios where uniform resource access is more important than efficiency. -This policy is heavily inspired by the equal share GPU scheduling policy used in NVIDIA's vGPU scheduler. - -Example: -``` json -"gpuDistributionPolicy": { - "type": "EQUAL_SHARE" -} -``` - -## First Fit Distribution Policy -This distributor allocates resources to consumers based on the first available supply that meets their demand. -It does not attempt to balance loads or optimize resource usage beyond the first fit principle. -It tries to place demands on already existing supplies before involving another supplier. -It assumes that the resource can be partitioned, if one supplier cannot satisfy the demand, it will try to combine multiple suppliers. - - -Example: -``` json -"gpuDistributionPolicy": { -"type": "FIRST_FIT" -} -``` - -## Fixed Share Distribution Policy - -This distributor allocates a dedicated, consistent portion of the requested resources to each VM (consumer), -ensuring predictable availability and stable performance. Each active consumer receives a fixed share of the total resource capacity, regardless of their individual demand or the demand of other consumers. -Key characteristics: -- Each consumer gets a fixed percentage of total resource capacity when active -- Unused shares (from inactive consumers) remain unallocated, not redistributed -- Performance remains consistent and predictable for each consumer -- Share allocation is based on maximum supported consumers, not currently active ones - -The share ratio is defined as a percentage of the total resource capacity, and it is applied uniformly across all active suppliers. -This policy is heavily inspired by the fixed share GPU scheduling policy used in NVIDIA's vGPU scheduler. - -Example: -``` json -"gpuDistributionPolicy": { - "type": "FIXED_SHARE", - "shareRatio": 0.5 -} -``` - -## Max Min Fairness Distribution Policy - -The MaxMinFairnessFlowDistributor implements the max-min fairness algorithm, -which prioritizes satisfying smaller demands first to ensure no consumer is completely starved of resources. -The algorithm sorts consumers by their demand levels and allocates resources incrementally, ensuring that the minimum allocation received by any consumer is maximized. -It maintains an overload state to track when total demand exceeds available supply and applies different distribution strategies accordingly. -**This is the default policy in OpenDC**. - -Example: -``` json -"gpuDistributionPolicy": { - "type": "MAX_MIN_FAIRNESS" -} -``` diff --git a/site/docs/documentation/Input/Topology/Host.md b/site/docs/documentation/Input/Topology/Host.md deleted file mode 100644 index ceedb12..0000000 --- a/site/docs/documentation/Input/Topology/Host.md +++ /dev/null @@ -1,129 +0,0 @@ -A host is a machine that can execute tasks. A host consist of the following components: - -| variable | type | required? | default | description | -|-----------------------|:-----------------------------------------------------------------------------|:----------|---------|--------------------------------------------------------------------------------| -| name | string | no | Host | The name of the host. This is only important for debugging and post-processing | -| count | integer | no | 1 | The amount of hosts of this type are in the cluster | -| cpuModel | [CPU](#cpu) | yes | N/A | The CPUs in the host | -| memory | [Memory](#memory) | yes | N/A | The memory used by the host | -| cpuPowerModel | [Power Model](/docs/documentation/Input/Topology/PowerModel) | no | Default | The power model used to determine the power draw of the cpu | -| gpuPowerModel | [Power Model](/docs/documentation/Input/Topology/PowerModel) | no | Default | The power model used to determine the power draw of the gpu | -| cpuDistributionPolicy | [Distribution Policy](/docs/documentation/Input/Topology/DistributionPolicy) | no | Default | The distribution policy used for the CPU | -| gpuDistributionPolicy | [Distribution Policy](/docs/documentation/Input/Topology/DistributionPolicy) | no | Default | The distribution policy used for the GPU | - -## CPU - -| variable | type | Unit | required? | default | description | -|-----------|---------|-------|-----------|---------|--------------------------------------------------| -| modelName | string | N/A | no | unknown | The name of the CPU. | -| vendor | string | N/A | no | unknown | The vendor of the CPU | -| arch | string | N/A | no | unknown | the micro-architecture of the CPU | -| count | integer | N/A | no | 1 | The number of CPUs of this type used by the host | -| coreCount | integer | count | yes | N/A | The number of cores in the CPU | -| coreSpeed | Double | Mhz | yes | N/A | The speed of each core in Mhz | - -## Memory - -| variable | type | Unit | required? | default | description | -|-------------|---------|------|-----------|---------|--------------------------------------------------------------------------| -| modelName | string | N/A | no | unknown | The name of the CPU. | -| vendor | string | N/A | no | unknown | The vendor of the CPU | -| arch | string | N/A | no | unknown | the micro-architecture of the CPU | -| memorySize | integer | Byte | yes | N/A | The number of cores in the CPU | -| memorySpeed | Double | Mhz | no | -1 | The speed of each core in Mhz. PLACEHOLDER: this currently does nothing. | - -## GPU - -GPUS are an optional component of a host. The required fields are only required if the host has GPUs. - -| variable | type | Unit | required? | default | description | -|------------------------------|-----------------------------------------------------------------------------------------------|------------------|-----------|---------|------------------------------------------------------------------------| -| modelName | string | N/A | no | unknown | The name of the GPU. | -| vendor | string | N/A | no | unknown | The vendor of the GPU | -| arch | string | N/A | no | unknown | the micro-architecture of the GPU | -| count | integer | N/A | no | 1 | The number of GPUs of this type used by the host | -| coreCount | integer | count | yes | N/A | The number of cores in the GPU | -| coreSpeed | Double | Mhz | yes | N/A | The speed of each core in Mhz | -| memorySize | integer | Byte | no | N/A | The speed of each core in Mhz | -| memoryBandwidth | Double | Bytes per Second | no | N/A | The speed of each core in Mhz | -| virtualizationOverHeadModel | [VirtualizationOverHeadModel](/docs/documentation/Input/Topology/VirtualizationOverHeadModel) | N/A | no | N/A | The virtualization model of the GPU, used to determine the performance | - -## Example - No GPU - -```json -{ - "name": "H01", - "cpu": { - "coreCount": 16, - "coreSpeed": 2100 - }, - "memory": { - "memorySize": 100000 - }, - "CpuPowerModel": { - "modelType": "sqrt", - "idlePower": 32.0, - "maxPower": 180.0 - }, - "count": 100 -} -``` - -This example creates 100 hosts with 16 cores and 2.1 Ghz CPU speed, and 100 GB of memory. -The power model used is a square root model with a power of 400 W, idle power of 32 W, and max power of 180 W. -For more information on the power model, see [Power Model](/docs/documentation/Input/Topology/PowerModel). - -## Example - With GPU - -```json -{ - "clusters": [ - { - "name": "C01", - "hosts": [ - { - "name": "DualGpuHost", - "cpu": { - "coreCount": 4, - "coreSpeed": 2000 - }, - "memory": { - "memorySize": 140457600000 - }, - "cpuPowerModel": { - "modelType": "linear", - "power": 400.0, - "idlePower": 100.0, - "maxPower": 200.0 - }, - "cpuDistributionPolicy": { - "type": "MAX_MIN_FAIRNESS" - }, - "gpu": { - "coreCount": 2, - "coreSpeed": 2000, - "virtualizationOverHeadModel": { - "type": "CONSTANT", - "percentageOverhead": 0.25 - } - }, - "gpuPowerModel": { - "modelType": "linear", - "power": 400.0, - "idlePower": 100.0, - "maxPower": 200.0 - }, - "gpuDistributionPolicy": { - "type": "FIXED_SHARE", - "shareRatio": 0.5 - } - } - ] - } - ] -} -``` - -This example creates a host with 4 CPU cores and 2 GPU cores, both with a speed of 2.0 Ghz. -The host has 140 GB of memory and uses a linear power model for both CPU and GPU with a power of 400 W, idle power of 100 W, and max power of 200 W. -For more information on the power model, see [Power Model](/docs/documentation/Input/Topology/PowerModel). diff --git a/site/docs/documentation/Input/Topology/PowerModel.md b/site/docs/documentation/Input/Topology/PowerModel.md deleted file mode 100644 index f782bb9..0000000 --- a/site/docs/documentation/Input/Topology/PowerModel.md +++ /dev/null @@ -1,36 +0,0 @@ -OpenDC uses power models to determine the power draw based on the utilization of a host. -All models in OpenDC are based on linear models interpolated between the idle and max power draw. -OpenDC currently supports the following power models: -1. **Constant**: The power draw is constant and does not depend on the utilization of the host. -2. **Sqrt**: The power draw interpolates between idle and max using a square root function. -3. **Linear**: The power draw interpolates between idle and max using a linear function. -4. **Square**: The power draw interpolates between idle and max using a square function. -5. **Cubic**The power draw interpolates between idle and max using a cubic function. -6. **MSE**: The power draw interpolates between idle and max using a mean square error function. -7. **Asymptotic**: The power draw interpolates between idle and max using an asymptotic function. - -The power model is defined using the following parameters: - -| variable | type | Unit | required? | default | description | -|-------------------|---------|------|-----------|---------|----------------------------------------------------------------------------| -| modelType | string | N/A | yes | N/A | The type of model used to determine power draw | -| power | double | Mhz | no | 400 | The power draw of a host when using the constant power draw model. | -| idlePower | double | Mhz | yes | N/A | The power draw of a host when idle in Watt. | -| maxPower | double | Mhz | yes | N/A | The power draw of a host when using max capacity in Watt. | -| calibrationFactor | double | Mhz | no | N/A | The parameter set to minimize the mean squared error. | -| asymUtil | double | Mhz | no | N/A | A utilization level at which the host attains asymptotic. | -| dvfs | Boolean | N/A | no | N/A | A flag indicates whether dynamic voltage and frequency scaling is enabled. | - - -## Example - -```json -{ - "modelType": "sqrt", - "idlePower": 32.0, - "maxPower": 180.0 -} -``` - -This creates a power model that uses a square root function to determine the power draw of a host. -The model uses an idle and max power of 32 W and 180 W respectively. diff --git a/site/docs/documentation/Input/Topology/PowerSource.md b/site/docs/documentation/Input/Topology/PowerSource.md deleted file mode 100644 index 993083d..0000000 --- a/site/docs/documentation/Input/Topology/PowerSource.md +++ /dev/null @@ -1,20 +0,0 @@ -Each cluster has a power source that provides power to the hosts in the cluster. -A user can connect a power source to a carbon trace to determine the carbon emissions during a workload. - -The power source consist of the following components: - -| variable | type | Unit | required? | default | description | -|-----------------|--------------|------|-----------|----------------|-----------------------------------------------------------------------------------| -| name | string | N/A | no | PowerSource | The name of the cluster. This is only important for debugging and post-processing | -| maxPower | integer | Watt | no | Long.Max_Value | The total power that the power source can provide in Watt. | -| carbonTracePath | path/to/file | N/A | no | null | A list of the hosts in a cluster. | - -## Example - -```json -{ - "carbonTracePath": "carbon_traces/AT_2021-2024.parquet" -} -``` - -This example creates a power source with infinite power draw that uses the carbon trace from the file `carbon_traces/AT_2021-2024.parquet`. diff --git a/site/docs/documentation/Input/Topology/Topology.md b/site/docs/documentation/Input/Topology/Topology.md deleted file mode 100644 index afc94e0..0000000 --- a/site/docs/documentation/Input/Topology/Topology.md +++ /dev/null @@ -1,183 +0,0 @@ -The topology of a datacenter defines all available hardware. Topologies are defined using a JSON file. -A topology consist of one or more clusters. Each cluster consist of at least one host on which jobs can be executed. -Each host consist of one or more CPUs, a memory unit and a power model. - -:::info Code -The code related to reading and processing topology files can be found [here](https://github.com/atlarge-research/opendc/tree/master/opendc-compute/opendc-compute-topology/src/main/kotlin/org/opendc/compute/topology) -::: - -In the following section, we describe the different components of a topology file. - -### Cluster - -| variable | type | required? | default | description | -|-------------|---------------------------------------------------------------|-----------|---------|-----------------------------------------------------------------------------------| -| name | string | no | Cluster | The name of the cluster. This is only important for debugging and post-processing | -| count | integer | no | 1 | The amount of clusters of this type are in the data center | -| hosts | List[[Host](/docs/documentation/Input/Topology/Host)] | yes | N/A | A list of the hosts in a cluster. | -| powerSource | [PowerSource](/docs/documentation/Input/Topology/PowerSource) | no | N/A | The power source used by all hosts connected to this cluster. | -| battery | [Battery](/docs/documentation/Input/Topology/Battery) | no | null | The battery used by a cluster to store energy. When null, no batteries are used. | - -Hosts, power sources and batteries all require objects to use. See their respective pages for more information. - -## Examples - -In the following section, we discuss several examples of topology files. - -### Simple - -The simplest data center that can be provided to OpenDC is shown below: - -```json -{ - "clusters": [ - { - "hosts": [ - { - "cpu": - { - "coreCount": 16, - "coreSpeed": 1000 - }, - "memory": { - "memorySize": 100000 - } - } - ], - "powerSource": { - "carbonTracePath": "carbon_traces/AT_2021-2024.parquet" - } - } - ] -} -``` - -This creates a data center with a single cluster containing a single host. This host consist of a single 16 core CPU -with a speed of 1 Ghz, and 100 MiB RAM memory. - -### Count - -Duplicating clusters, hosts, or CPUs is easy using the "count" keyword: - -```json -{ - "clusters": [ - { - "count": 2, - "hosts": [ - { - "count": 5, - "cpu": - { - "coreCount": 16, - "coreSpeed": 1000, - "count": 10 - }, - "memory": - { - "memorySize": 100000 - } - } - ], - "powerSource": { - "carbonTracePath": "carbon_traces/AT_2021-2024.parquet" - } - } - ] -} -``` - -This topology creates a datacenter consisting of 2 clusters, both containing 5 hosts. Each host contains 10 16 core -CPUs. -Using "count" saves a lot of copying. - -### Complex - -Following is an example of a more complex topology: - -```json -{ - "clusters": [ - { - "name": "C01", - "count": 2, - "hosts": [ - { - "name": "H01", - "count": 2, - "cpus": [ - { - "coreCount": 16, - "coreSpeed": 1000 - } - ], - "memory": { - "memorySize": 1000000 - }, - "powerModel": { - "modelType": "linear", - "idlePower": 200.0, - "maxPower": 400.0 - } - }, - { - "name": "H02", - "count": 2, - "cpus": [ - { - "coreCount": 8, - "coreSpeed": 3000 - } - ], - "memory": { - "memorySize": 100000 - }, - "powerModel": { - "modelType": "square", - "idlePower": 300.0, - "maxPower": 500.0 - } - } - ] - } - ] -} -``` - -This topology defines two types of hosts with different coreCount, and coreSpeed. -Both types of hosts are created twice. - - -### With Units of Measure - -Aside from using number to indicate values it is also possible to define values using strings. This allows the user to define the unit of the input parameter. -```json -{ - "clusters": [ - { - "count": 2, - "hosts" : - [ - { - "name": "H01", - "cpuModel": - { - "coreCount": 8, - "coreSpeed": "3.2 Ghz" - }, - "memory": { - "memorySize": "128e3 MiB", - "memorySpeed": "1 Mhz" - }, - "powerModel": { - "modelType": "linear", - "power": "400 Watts", - "maxPower": "1 KW", - "idlePower": "0.4 W" - } - } - ] - } - ] -} -``` diff --git a/site/docs/documentation/Input/Topology/VirtualizationOverHeadModel.md b/site/docs/documentation/Input/Topology/VirtualizationOverHeadModel.md deleted file mode 100644 index 8bc2155..0000000 --- a/site/docs/documentation/Input/Topology/VirtualizationOverHeadModel.md +++ /dev/null @@ -1,39 +0,0 @@ -OpenDC offers to model the overhead of virtualization in a GPU. Overhead is modelled as decrease of supply. -Three different models are available to represent the performance overhead of virtualization in a GPU as described below. -It is not required to define a virtualization overhead model for a GPU, in which case the GPU is assumed to be used directly by the VM without any overhead. - - -## No Overhead - -This model assumes that there is no overhead when using a GPU. -This type of overhead occurs when GPU pass-through or Para or Full virtualization is used. -In this case, the GPU is used directly by the VM and there is no overhead. - -```` json -"virtualizationOverHeadModel": { - "type": "NONE" -} -```` - -## Constant Overhead - -The constant overhead model assumes that there is a fixed percentage of performance overhead when using a GPU. -The overhead can be customized by setting the `percentageOverhead` parameter. - -``` json -"virtualizationOverHeadModel": { - "type": "CONSTANT", - "percentageOverhead": 0.25 -} -``` - -## Share Based Overhead - -The share based overhead model assumes that the performance overhead is based on the number of VMs sharing the GPU. -This is based on the insights of the paper by Garg et al. [Empirical Analysis of Hardware-Assisted GPU Virtualization](https://doi.org/10.1109/HiPC.2019.00054). - -``` json -"virtualizationOverHeadModel": { - "type": "SHARE_BASED" -} -``` diff --git a/site/docs/documentation/Input/Workload.md b/site/docs/documentation/Input/Workload.md deleted file mode 100644 index 3415dbd..0000000 --- a/site/docs/documentation/Input/Workload.md +++ /dev/null @@ -1,36 +0,0 @@ -Workloads define what tasks in the simulation, when they were submitted, and their computational requirements. -Workload are defined using two files: - -- **[Tasks](#tasks)**: The Tasks file contains the metadata of the tasks -- **[Fragments](#fragments)**: The Fragments file contains the computational demand of each task over time - -Both files are provided using the parquet format. - -#### Tasks -The Tasks file provides an overview of the tasks: - -| Metric | Required? | Datatype | Unit | Summary | -|------------------|-----------|----------|------------------------------|---------------------------------------------------------------------| -| id | Yes | string | | The id of the server | -| submission_time | Yes | int64 | datetime | The submission time of the server | -| nature | No | string | [deferrable, non-deferrable] | Defines if a task can be delayed | -| deadline | No | string | datetime | The latest the scheduling of a task can be delayed to. | -| duration | Yes | int64 | datetime | The finish time of the submission | -| cpu_count | Yes | int32 | count | The number of CPUs required to run this task | -| cpu_capacity | Yes | float64 | MHz | The amount of CPU required to run this task | -| mem_capacity | Yes | int64 | MB | The amount of memory required to run this task | -| gpu_count | No | int32 | count | The number of GPUs required to run this task | -| gpu_capacity | No | float64 | MHz | The amount of GPU required to run this task | -| gpu_mem_capacity | No | int64 | MB | The amount of memory required to run this task. (Currently ignored) | - -#### Fragments -The Fragments file provides information about the computational demand of each task over time: - -| Metric | Required? | Datatype | Unit | Summary | -|-----------|-----------|----------|---------------|-------------------------------------------------| -| id | Yes | string | | The id of the task | -| duration | Yes | int64 | milli seconds | The duration since the last sample | -| cpu_count | Yes | int32 | count | The number of cpus required | -| cpu_usage | Yes | float64 | MHz | The amount of computational CPU power required. | -| gpu_count | No | int32 | count | The number of gpus required | -| gpu_usage | No | float64 | MHz | The amount of computational GPU power required. | diff --git a/site/docs/documentation/Input/_category_.json b/site/docs/documentation/Input/_category_.json deleted file mode 100644 index e433770..0000000 --- a/site/docs/documentation/Input/_category_.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "label": "Input", - "position": 1, - "link": { - "type": "generated-index" - } -} diff --git a/site/docs/documentation/M3SA/M3SA.md b/site/docs/documentation/M3SA/M3SA.md deleted file mode 100644 index 6c97d20..0000000 --- a/site/docs/documentation/M3SA/M3SA.md +++ /dev/null @@ -1,92 +0,0 @@ -M3SA is setup using a json file. The Multi-Model is a top-layer applied on top of the -simulator, -capable to leverage into a singular tool the prediction of multiple models. The Meta-Model is a model generated from the -Multi-Model, and predicts using the predictions of individual models. - -The Multi-Model's properties can be set using a JSON file. The JSON file must be linked to the scenario file and is -required -to follow the structure below. - -## Schema - -The schema for the scenario file is provided in [schema](M3SASchema.md) -In the following section, we describe the different components of the schema. - -### General Structure - -| Variable | Type | Required? | Default | Possible Answers | Description | -|------------------------|---------|-----------|---------------|-------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| multimodel | boolean | no | true | true, false | Whether or not to build a Multi-Model. If set to false, a Meta-Model will not be computed either. | -| metamodel | boolean | no | true | true, false | Whether to build a Meta-Model. | -| metric | string | yes | N/A | N/A | What metric to be analyzed from the computed files. | -| current_unit | string | no | "" | any string (e.g., "CO2", "Wh") | The international system unit of the metric to be analyzed, without prefixes. e.g., "W" for Watt is ok, "kW" is not. | -| unit_scaling_magnitude | integer | no | 10 | -9, -6, -3, 1, 3, 6, 9 | The scaling factor to be applied to the metric (10^-9, 10^-6, 10^3, 10^3, 10^6, 10^9). For no scaling, input 1. | -| window_size | integer | no | 1 | any positive, non-zero, integer | The size of the window, used for aggregating the chunks. | -| window_function | string | no | "mean" | "mean", "median" | The function used by the window for aggregating the chunks (e.g., for "mean", the window will compute the mean of the samples). | -| meta_function | string | no | "mean" | "mean", "median" | The function used by the Meta-Model to be generated. For "mean", the Meta-Model takes the mean of the individual models, at the granularity established by the window-size. | -| samples_per_minute | double | no | N/A | any positive, non-zero, double | The number of samples per minute, in the prediction data (simulator export rate). e.g., "0.2" means 1 sample every 5 minutes, "20" means a 20 samples per minute, or 1 sample every 3 seconds. | -| seed | integer | no | 0 | any integer >= 0 | The seed of the simulation. This must correspond to the seed from the output folder (from seed=x). | -| plot_type | string | no | "time_series" | "time_series", "cumulative", "cumulative_time_series" | The type of the plot, generated by the Multi-Model and Meta-Model. | -| plot_title | string | no | "" | any string | The title of the plot. | -| x_ticks_count | integer | no | None | any integer, larger than 0 | The number of ticks on x-axis. | -| y_ticks_count | integer | no | None | any integer, larger than 0 | The number of ticks on y-axis. | -| x_label | string | no | "Time" | any string | The label for the x-axis of the plot. | -| y_label | string | no | "Metric Unit" | any string | The label for the y-axis of the plot. | -| y_min | double | no | None | any positive, non-zero, double | The minimum value for the vertical axis of the plot. | -| y_max | double | no | None | any positive, non-zero, double | The maximum value for the vertical axis of the plot. | -| x_min | double | no | None | any positive, non-zero, double | The minimum value for the horizontal axis of the plot. | -| x_max | double | no | None | any positive, non-zero, double | The maximum value for the horizontal axis of the plot. | - -## Examples - -In the following section, we discuss several examples of M3SA setup files. Any setup file can be verified -using the JSON schema defined in [schema](M3SASchema.md). - -### Simple - -The simplest M3SA setup that can be provided to OpenDC is shown below: - -```json -{ - "metric": "power_draw" -} -``` - -This configuration creates a Multi-Model and Meta-Model on the power_draw. All the other parameters are handled by the -default values, towards reducing the complexity of the setup. - -### Complex - -A more complex M3SA setup, where the user has more control on teh generated output, is show below: - -```json -{ - "multimodel": true, - "metamodel": false, - "metric": "carbon_emission", - "window_size": 10, - "window_function": "median", - "metamodel_function": "mean", - "samples_per_minute": 0.2, - "unit_scaling_magnitude": 1000, - "current_unit": "gCO2", - "seed": 0, - "plot_type": "cumulative_time_series", - "plot_title": "Carbon Emission Prediction", - "x_label": "Time [days]", - "y_label": "Carbon Emission [gCO2/kWh]", - "x_min": 0, - "x_max": 200, - "y_min": 500, - "y_max": 1000, - "x_ticks_count": 3, - "y_ticks_count": 3 -} -``` - -This configuration creates a Multi-Model and a Meta-Model which predicts the carbon_emission. The window size is 10, and -the aggregation function (for the window) is median. The Meta-Model function is mean. The data has been exported at a -rate of 0.2 samples per minute (i.e., a sample every 5 minutes). The plot type is cummulative_time_series, which starts -from a y-axis value of 500 and goes up to 1000. Therefore, the Multi-Model and the Meta-Model will show only -the values greater than y_min (500) and smaller than y_max (1000). Also, the x-axis will start from 0 and go up to 200, -with 3 ticks on the x-axis and 3 ticks on the y-axis. diff --git a/site/docs/documentation/M3SA/M3SASchema.md b/site/docs/documentation/M3SA/M3SASchema.md deleted file mode 100644 index 5a3503c..0000000 --- a/site/docs/documentation/M3SA/M3SASchema.md +++ /dev/null @@ -1,115 +0,0 @@ -Below is the schema for the MultiMetaModel JSON file. This schema can be used to validate a MultiMetaModel setup file. -A setup file can be validated using a JSON schema validator, such as https://www.jsonschemavalidator.net/. - -```json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "multimodel": { - "type": "boolean", - "default": true, - "description": "Whether or not to build a Multi-Model. If set to false, a Meta-Model will not be computed either." - }, - "metamodel": { - "type": "boolean", - "default": true, - "description": "Whether to build a Meta-Model." - }, - "metric": { - "type": "string", - "description": "What metric to be analyzed from the computed files." - }, - "current_unit": { - "type": "string", - "default": "", - "description": "The international system unit of the metric to be analyzed, without prefixes. e.g., 'W' for Watt is ok, 'kW' is not." - }, - "unit_scaling_magnitude": { - "type": "integer", - "default": 10, - "enum": [-9, -6, -3, 1, 3, 6, 9], - "description": "The scaling factor to be applied to the metric (10^-9, 10^-6, 10^3, 10^3, 10^6, 10^9). For no scaling, input 1." - }, - "seed": { - "type": "integer", - "default": 0, - "minimum": 0, - "description": "The seed of the simulation. This must correspond to the seed from the output folder (from seed=x)." - }, - "window_size": { - "type": "integer", - "default": 1, - "minimum": 1, - "description": "The size of the window, used for aggregating the chunks." - }, - "window_function": { - "type": "string", - "default": "mean", - "enum": ["mean", "median"], - "description": "The function used by the window for aggregating the chunks (e.g., for 'mean', the window will compute the mean of the samples)." - }, - "meta_function": { - "type": "string", - "default": "mean", - "enum": ["mean", "median"], - "description": "The function used by the Meta-Model to be generated. For 'mean', the Meta-Model takes the mean of the individual models, at the granularity established by the window-size." - }, - "samples_per_minute": { - "type": "number", - "minimum": 0.0001, - "description": "The number of samples per minute, in the prediction data (simulator export rate). e.g., '0.2' means 1 sample every 5 minutes, '20' means 20 samples per minute, or 1 sample every 3 seconds." - }, - "plot_type": { - "type": "string", - "default": "time_series", - "enum": ["time_series", "cumulative", "cumulative_time_series"], - "description": "The type of the plot, generated by the Multi-Model and Meta-Model." - }, - "plot_title": { - "type": "string", - "default": "", - "description": "The title of the plot." - }, - "x_label": { - "type": "string", - "default": "Time", - "description": "The label for the x-axis of the plot." - }, - "y_label": { - "type": "string", - "default": "Metric Unit", - "description": "The label for the y-axis of the plot." - }, - "y_min": { - "type": "number", - "description": "The minimum value for the vertical axis of the plot." - }, - "y_max": { - "type": "number", - "description": "The maximum value for the vertical axis of the plot." - }, - "x_min": { - "type": "number", - "description": "The minimum value for the horizontal axis of the plot." - }, - "x_max": { - "type": "number", - "description": "The maximum value for the horizontal axis of the plot." - }, - "x_ticks_count": { - "type": "integer", - "minimum": 1, - "description": "The number of ticks on x-axis." - }, - "y_ticks_count": { - "type": "integer", - "minimum": 1, - "description": "The number of ticks on y-axis." - } - }, - "required": [ - "metric" - ] -} -``` diff --git a/site/docs/documentation/Output.md b/site/docs/documentation/Output.md deleted file mode 100644 index 6c43f5d..0000000 --- a/site/docs/documentation/Output.md +++ /dev/null @@ -1,131 +0,0 @@ - -Running OpenDC results in five output files: -1. [Task](#task) contains metrics related to the jobs being executed. -2. [Host](#host) contains all metrics related to the hosts on which jobs can be executed. -3. [Power Source](#power-source) contains all metrics related to the power sources that power the hosts. -4. [Battery](#battery) contains all metrics related to the batteries that power the hosts. -5. [Service](#service) contains metrics describing the overall performance. - -User can define which files, and features are to be included in the output in the experiment file (see [ExportModel](/docs/documentation/Input/ExportModel.md)). - -### Task -The task output file, contains all metrics of related to the tasks that are being executed. - -| Metric | Datatype | Unit | Summary | -|--------------------|----------|-----------|-----------------------------------------------------------------------------| -| timestamp | int64 | ms | Timestamp of the sample since the start of the workload. | -| timestamp_absolute | int64 | ms | The absolute timestamp based on the given workload. | -| task_id | binary | string | The id of the task determined during runtime. | -| task_name | binary | string | The name of the task provided by the Trace. | -| host_name | binary | string | The id of the host on which the task is hosted or `null` if it has no host. | -| mem_capacity | int64 | Mb | The memory required by the task. | -| cpu_count | int32 | count | The number of CPUs required by the task. | -| cpu_limit | double | MHz | The capacity of the CPUs of Host on which the task is running. | -| cpu_usage | double | MHz | The cpu capacity provided by the CPU to the task. | -| cpu_demand | double | MHz | The cpu capacity demanded of the CPU by the task. | -| cpu_time_active | int64 | ms | The duration that a CPU was active in the task. | -| cpu_time_idle | int64 | ms | The duration that a CPU was idle in the task. | -| cpu_time_steal | int64 | ms | The duration that a vCPU wanted to run, but no capacity was available. | -| cpu_time_lost | int64 | ms | The duration of CPU time that was lost due to interference. | -| gpu_limit | double | MHz | The capacity of the GPUs of Host on which the task is running. | -| gpu_usage | double | MHz | The gpu capacity provided by the GPU to the task. | -| gpu_demand | double | MHz | The gpu capacity demanded of the GPU by the task. | -| gpu_time_active | int64 | ms | The duration that a GPU was active in the task. | -| gpu_time_idle | int64 | ms | The duration that a GPU was idle in the task. | -| gpu_time_steal | int64 | ms | The duration that a vGPU wanted to run, but no capacity was available. | -| gpu_time_lost | int64 | ms | The duration of GPU time that was lost due to interference. | -| uptime | int64 | ms | The uptime of the host since last sample. | -| downtime | int64 | ms | The downtime of the host since last sample. | -| num_failures | int64 | count | How many times was a task interrupted due to machine failure. | -| num_pauses | int64 | ms | How many times was a task interrupted due to the TaskStopper. | -| submission_time | int64 | ms | The time for which the task was enqueued for the scheduler. | -| schedule_time | int64 | ms | The time at which task got booted. | -| finish_time | int64 | ms | The time at which the task was finished (either completed or terminated). | -| task_state | String | TaskState | The current state of the Task. | - -### Host -The host output file, contains all metrics of related to the hosts that are running. -For each GPU attached to the host, there is a separate metric for each GPU. -The metrics are named `gpu_capacity_n`, `gpu_usage_n`, `gpu_demand_n`, etc., where `n` is the index of the GPU starting from 0. - -| Metric | DataType | Unit | Summary | -|--------------------|----------|------------|--------------------------------------------------------------------------------------------------------------| -| timestamp | int64 | ms | Timestamp of the sample. | -| timestamp_absolute | int64 | ms | The absolute timestamp based on the given workload. | -| host_name | binary | string | The name of the host. | -| cluster_name | binary | string | The name of the cluster that this host is part of. | -| cpu_count | int32 | count | The number of cores in this host. | -| mem_capacity | int64 | Mb | The amount of available memory. | -| tasks_terminated | int32 | count | The number of tasks that are in a terminated state. | -| tasks_running | int32 | count | The number of tasks that are in a running state. | -| tasks_error | int32 | count | The number of tasks that are in an error state. | -| tasks_invalid | int32 | count | The number of tasks that are in an unknown state. | -| cpu_capacity | double | MHz | The total capacity of the CPUs in the host. | -| cpu_usage | double | MHz | The total CPU capacity provided to all tasks on this host. | -| cpu_demand | double | MHz | The total CPU capacity demanded by all tasks on this host. | -| cpu_utilization | double | ratio | The CPU utilization of the host. This is calculated by dividing the cpu_usage, by the cpu_capacity. | -| cpu_time_active | int64 | ms | The duration that a CPU was active in the host. | -| cpu_time_idle | int64 | ms | The duration that a CPU was idle in the host. | -| cpu_time_steal | int64 | ms | The duration that a vCPU wanted to run, but no capacity was available. | -| cpu_time_lost | int64 | ms | The duration of CPU time that was lost due to interference. | -| gpu_capacity_n | double | MHz | The total capacity of the the n-th GPU in the host. | -| gpu_usage_n | double | MHz | The total of the n-th GPU capacity provided to all tasks on this host. | -| gpu_demand_n | double | MHz | The total of the n-th GPU capacity demanded by all tasks on this host. | -| gpu_utilization_n | double | ratio | The the n-th GPU utilization of the host. This is calculated by dividing the gpu_usage, by the gpu_capacity. | -| gpu_time_active_n | int64 | ms | The duration that the n-th GPU was active in the host. | -| gpu_time_idle_n | int64 | ms | The duration that the n-th GPU was idle in the host. | -| gpu_time_steal_n | int64 | ms | The duration that the n-th vGPU wanted to run, but no capacity was available. | -| gpu_time_lost_n | int64 | ms | The duration of the n-th GPU time that was lost due to interference. | -| power_draw | double | Watt | The current power draw of the host. | -| energy_usage | double | Joule (Ws) | The total energy consumption of the host since last sample. | -| embodied_carbon | double | gram | The total embodied carbon emitted since the last sample. | -| uptime | int64 | ms | The uptime of the host since last sample. | -| downtime | int64 | ms | The downtime of the host since last sample. | -| boot_time | int64 | ms | The time a host got booted. | -| boot_time_absolute | int64 | ms | The absolute time a host got booted. | - -### Power Source -The power source output file, contains all metrics of related to the power sources. - -| Metric | DataType | Unit | Summary | -|--------------------|----------|------------|-------------------------------------------------------------------| -| timestamp | int64 | ms | Timestamp of the sample. | -| timestamp_absolute | int64 | ms | The absolute timestamp based on the given workload. | -| source_name | binary | string | The name of the power source. | -| cluster_name | binary | string | The name of the cluster that this power source is part of. | -| power_draw | double | Watt | The current power draw of the host. | -| energy_usage | double | Joule (Ws) | The total energy consumption of the host since last sample. | -| carbon_intensity | double | gCO2/kW | The amount of carbon that is emitted when using a unit of energy. | -| carbon_emission | double | gram | The amount of carbon emitted since the previous sample. | - -### Battery -The host output file, contains all metrics of related batteries. - -| Metric | DataType | Unit | Summary | -|--------------------|----------|--------------|-------------------------------------------------------------------| -| timestamp | int64 | ms | Timestamp of the sample. | -| timestamp_absolute | int64 | ms | The absolute timestamp based on the given workload. | -| battery_name | binary | string | The name of the battery. | -| cluster_name | binary | string | The name of the cluster that this battery is part of. | -| power_draw | double | Watt | The current power draw of the host. | -| energy_usage | double | Joule (Ws) | The total energy consumption of the host since last sample. | -| carbon_intensity | double | gCO2/kW | The amount of carbon that is emitted when using a unit of energy. | -| embodied_carbon | double | gram | The total embodied carbon emitted since the last sample. | -| charge | double | Joule | The current charge of the battery. | -| capacity | double | Joule | The total capacity of the battery. | -| battery_state | String | BatteryState | The current state of the battery. | - -### Service -The service output file, contains metrics providing an overview of the performance. - -| Metric | DataType | Unit | Summary | -|--------------------|----------|-------|-------------------------------------------------------| -| timestamp | int64 | ms | Timestamp of the sample | -| timestamp_absolute | int64 | ms | The absolute timestamp based on the given workload | -| hosts_up | int32 | count | The number of hosts that are up at this instant. | -| hosts_down | int32 | count | The number of hosts that are down at this instant. | -| tasks_total | int32 | count | The number of tasks seen by the service. | -| tasks_pending | int32 | count | The number of tasks that are pending to be scheduled. | -| tasks_active | int32 | count | The number of tasks that are currently active. | -| tasks_terminated | int32 | count | The number of tasks that were terminated. | -| tasks_completed | int32 | count | The number of tasks that finished successfully | diff --git a/site/docs/documentation/_category_.json b/site/docs/documentation/_category_.json deleted file mode 100644 index 0776466..0000000 --- a/site/docs/documentation/_category_.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "label": "Documentation", - "position": 5, - "link": { - "type": "generated-index" - } -} diff --git a/site/docs/getting-started/0-installation.md b/site/docs/getting-started/0-installation.md deleted file mode 100644 index 76ffd01..0000000 --- a/site/docs/getting-started/0-installation.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -description: How to install OpenDC locally, and start experimenting in no time. ---- - -# Installation - -This page describes how to set up and configure a local single-user OpenDC installation so that you can quickly get your -experiments running. You can also use the [hosted version of OpenDC](https://app.opendc.org) to get started even -quicker (The web server is however missing some more complex features). - - -## Prerequisites - -1. **Supported Platforms** - OpenDC is actively tested on Windows, macOS and GNU/Linux. -2. **Required Software** - A Java installation of version 21 or higher is required for OpenDC. You may download the - [Java distribution from Oracle](https://www.oracle.com/java/technologies/downloads/) or use the distribution provided - by your package manager. - -## Download - -To get an OpenDC distribution, download a recent version from our [Releases](https://github.com/atlarge-research/opendc/releases) page on GitHub. -For basic usage, the OpenDCExperimentRunner is all that is needed. - -## Setup - -Unpack the downloaded OpenDC distribution. Opening OpenDCExperimentRunner results in two folders, `bin` and `lib`. -`lib` contains all `.jar` files needed to run OpenDC. `bin` two executable versions of the OpenDCExperimentRunner. -In the following pages, we discuss how to run an experiment using the executables. - diff --git a/site/docs/getting-started/1-start-using-intellij.md b/site/docs/getting-started/1-start-using-intellij.md deleted file mode 100644 index 6aec91f..0000000 --- a/site/docs/getting-started/1-start-using-intellij.md +++ /dev/null @@ -1,172 +0,0 @@ - - -# In this How-To we explain how you setup IntelliJ IDEA - -First of all you can download IntelliJ here: https://lp.jetbrains.com/intellij-idea-promo/ - -# Basic steps - -``` -git clone git@github.com:atlarge-research/opendc -``` - -Check if you have a compatible java version available. Make sure to have one of these versions available: [21] - -If not install a supported version! - -On a MAC - -``` -/usr/libexec/java_home -V -``` - -On Debian - -``` -update-alternatives --list java -``` - -On Redhat/Centos - -``` -yum list installed | grep java -``` - - -Open the project in IntelliJ - -![Intellij Open Project](img/intellij_open_project.png) - -Now fix the settings so that you use the correct java version. (In the example the java version is set to "21") -Navigation path in the settings pannel: "Build, Execution, Deployment" -> "Build Tools" -> "Gradle" - -![Intellij Settings](img/intellij_settings.png) - -Now navigate in the file menu to and open the file: "gradle"/"libs.versions.toml" - -Make sure the java version is set to the same version as previously cofigured in the settings. - -![Intellij Libs Versions Toml](img/intellij_libs_versions_toml.png) - - -Now open the Gradle panel on the right-hand side of the editor (1) and hit the refresh button at the top of the panel (2). - -![Intellij Gradle Panel](img/intellij_gradle_panel.png) - - -# Setup your first experiment and run it from source - - -Create a directory where you are going to put the files for your first experiment. - -File structure: - -![Experiment File Structure](img/experiment_file_structure.png) - -You can download the example workload trace (bitbrains-small-9d2e576e6684ddc57c767a6161e66963.zip) [here](https://atlarge-research.github.io/opendc/assets/files/bitbrains-small-9d2e576e6684ddc57c767a6161e66963.zip) - -Now unzip the trace. - -The content of "topology.json" - -``` -{ - "clusters": - [ - { - "name": "C01", - "hosts" : - [ - { - "name": "H01", - "cpu": - { - "coreCount": 32, - "coreSpeed": 3200 - }, - "memory": { - "memorySize": 256000 - } - } - ] - }, - { - "name": "C02", - "hosts" : - [ - { - "name": "H02", - "count": 6, - "cpu": - { - "coreCount": 8, - "coreSpeed": 2930 - }, - "memory": { - "memorySize": 64000 - } - } - ] - }, - { - "name": "C03", - "hosts" : - [ - { - "name": "H03", - "count": 2, - "cpu": - { - "coreCount": 16, - "coreSpeed": 3200 - }, - "memory": { - "memorySize": 128000 - } - } - ] - } - ] -} -``` - -The content of "experiment.json" - -The paths in the "experiment.json" file are relative to the "working directory" which is configured next. - - -``` -{ - "name": "simple", - "topologies": [{ - "pathToFile": "topology.json" - }], - "workloads": [{ - "pathToFile": "bitbrains-small", - "type": "ComputeWorkload" - }] -} -``` - -In the project file structure on the left open the following file: - -"opendc-experiments"/"opendc-experiments-base"/"src"/"main"/"kotlin"/"org.opendc.experiment.base"/"runner"/"ExperimentCLi.kt" - -![Intellij Experimentcli](img/Intellij_experimentcli.png) - -Now open the "Run/Debug" configuration (top right). - -![Intellij Open Run Config](img/intellij_open_run_config.png) - -We need to edit two settings: - -"Program arguments": --experiment-path experiment.json - -"Working Directory": a path where you have put the experiment files - -![Intellij Edit The Run Config](img/intellij_edit_the_run_config.png) - -Now you can click "Run" and start your first experiment. - -In the working directory a "output" direcotry is created with the results of the experiment. - diff --git a/site/docs/getting-started/2-whats-next.md b/site/docs/getting-started/2-whats-next.md deleted file mode 100644 index b759802..0000000 --- a/site/docs/getting-started/2-whats-next.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -description: How to supercharge your designs and experiments with OpenDC. ---- - -# What's next? - -Congratulations! You have just learned how to design and experiment with a (virtual) datacenter in OpenDC. What's next? - -- Follow one of the [tutorials](/docs/category/tutorials) using OpenDC. -- Read about [existing work using OpenDC](/community/research). -- Get involved in the [OpenDC Community](/community/support). -- If you are interested in contributing to OpenDC you can find a How-To here [4-start-using-intellij](1-start-using-intellij.md), please also read https://github.com/atlarge-research/opendc/blob/master/CONTRIBUTING.md. diff --git a/site/docs/getting-started/_category_.json b/site/docs/getting-started/_category_.json index 169f7a2..2ac18b0 100644 --- a/site/docs/getting-started/_category_.json +++ b/site/docs/getting-started/_category_.json @@ -3,6 +3,6 @@ "position": 2, "link": { "type": "generated-index", - "description": "10 minutes to learn the most important concepts of OpenDC." + "description": "Get OpenDT running on your machine." } } diff --git a/site/docs/getting-started/img/Intellij_experimentcli.png b/site/docs/getting-started/img/Intellij_experimentcli.png deleted file mode 100644 index fceed49..0000000 Binary files a/site/docs/getting-started/img/Intellij_experimentcli.png and /dev/null differ diff --git a/site/docs/getting-started/img/experiment_file_structure.png b/site/docs/getting-started/img/experiment_file_structure.png deleted file mode 100644 index 8b0b8f3..0000000 Binary files a/site/docs/getting-started/img/experiment_file_structure.png and /dev/null differ diff --git a/site/docs/getting-started/img/intellij_edit_the_run_config.png b/site/docs/getting-started/img/intellij_edit_the_run_config.png deleted file mode 100644 index fae35b5..0000000 Binary files a/site/docs/getting-started/img/intellij_edit_the_run_config.png and /dev/null differ diff --git a/site/docs/getting-started/img/intellij_edit_the_run_config.psd b/site/docs/getting-started/img/intellij_edit_the_run_config.psd deleted file mode 100644 index b178fdb..0000000 Binary files a/site/docs/getting-started/img/intellij_edit_the_run_config.psd and /dev/null differ diff --git a/site/docs/getting-started/img/intellij_gradle_panel.png b/site/docs/getting-started/img/intellij_gradle_panel.png deleted file mode 100644 index c3c98e1..0000000 Binary files a/site/docs/getting-started/img/intellij_gradle_panel.png and /dev/null differ diff --git a/site/docs/getting-started/img/intellij_gradle_panel.psd b/site/docs/getting-started/img/intellij_gradle_panel.psd deleted file mode 100644 index a52f0c9..0000000 Binary files a/site/docs/getting-started/img/intellij_gradle_panel.psd and /dev/null differ diff --git a/site/docs/getting-started/img/intellij_libs_versions_toml.png b/site/docs/getting-started/img/intellij_libs_versions_toml.png deleted file mode 100644 index a27f7cc..0000000 Binary files a/site/docs/getting-started/img/intellij_libs_versions_toml.png and /dev/null differ diff --git a/site/docs/getting-started/img/intellij_libs_versions_toml.psd b/site/docs/getting-started/img/intellij_libs_versions_toml.psd deleted file mode 100644 index ae27af2..0000000 Binary files a/site/docs/getting-started/img/intellij_libs_versions_toml.psd and /dev/null differ diff --git a/site/docs/getting-started/img/intellij_open_project.png b/site/docs/getting-started/img/intellij_open_project.png deleted file mode 100644 index c04f536..0000000 Binary files a/site/docs/getting-started/img/intellij_open_project.png and /dev/null differ diff --git a/site/docs/getting-started/img/intellij_open_run_config.png b/site/docs/getting-started/img/intellij_open_run_config.png deleted file mode 100644 index a9c4436..0000000 Binary files a/site/docs/getting-started/img/intellij_open_run_config.png and /dev/null differ diff --git a/site/docs/getting-started/img/intellij_settings.png b/site/docs/getting-started/img/intellij_settings.png deleted file mode 100644 index 6bbda7e..0000000 Binary files a/site/docs/getting-started/img/intellij_settings.png and /dev/null differ diff --git a/site/docs/getting-started/img/intellij_settings.psd b/site/docs/getting-started/img/intellij_settings.psd deleted file mode 100644 index f9affd8..0000000 Binary files a/site/docs/getting-started/img/intellij_settings.psd and /dev/null differ diff --git a/site/docs/getting-started/installation.md b/site/docs/getting-started/installation.md new file mode 100644 index 0000000..1006c64 --- /dev/null +++ b/site/docs/getting-started/installation.md @@ -0,0 +1,32 @@ +--- +sidebar_position: 2 +--- + +# Installation + +## Clone the Repository + +```bash +git clone https://github.com/atlarge-research/opendt.git +cd opendt +``` + +## Setup Development Environment + +Run the setup command to create a Python virtual environment and install dependencies: + +```bash +make setup +``` + +This creates a `.venv` directory with all required Python packages. + +## Verify Setup + +Check that Docker can access the required images: + +```bash +docker compose config +``` + +This validates the Docker Compose configuration without starting services. diff --git a/site/docs/getting-started/prerequisites.md b/site/docs/getting-started/prerequisites.md new file mode 100644 index 0000000..0b78c87 --- /dev/null +++ b/site/docs/getting-started/prerequisites.md @@ -0,0 +1,45 @@ +--- +sidebar_position: 1 +--- + +# Prerequisites + +Before running OpenDT, ensure you have the following installed: + +## Required Software + +| Software | Version | Purpose | +|----------|---------|---------| +| Docker | 20.10+ | Container runtime | +| Docker Compose | 2.0+ | Multi-container orchestration | +| Make | Any | Build automation | + +## Verify Installation + +```bash +docker --version +docker compose version +make --version +``` + +## System Requirements + +| Resource | Minimum | Recommended | +|----------|---------|-------------| +| RAM | 8 GB | 16 GB | +| CPU | 4 cores | 8 cores | +| Disk | 10 GB | 20 GB | + +OpenDT runs multiple containers including Kafka, Grafana, and the simulation services. More RAM and CPU cores will improve performance, especially for faster simulation speeds. + +## Network Ports + +OpenDT uses the following ports by default: + +| Port | Service | +|------|---------| +| 3000 | Grafana Dashboard | +| 3001 | API (OpenAPI/Swagger) | +| 9092 | Kafka (internal) | + +Ensure these ports are available before starting. diff --git a/site/docs/getting-started/running.md b/site/docs/getting-started/running.md new file mode 100644 index 0000000..a454fef --- /dev/null +++ b/site/docs/getting-started/running.md @@ -0,0 +1,59 @@ +--- +sidebar_position: 3 +--- + +# Running OpenDT + +## Start the System + +Start all services with the default configuration: + +```bash +make up +``` + +Or specify a configuration file: + +```bash +make up config=config/experiments/experiment_1.yaml +``` + +## Access the Dashboard + +Once services are running: + +| URL | Description | +|-----|-------------| +| http://localhost:3000 | Grafana Dashboard | +| http://localhost:3001 | API Documentation | + +The Grafana dashboard shows real-time power consumption data as the simulation progresses. + +## Monitor Progress + +View logs from a specific service: + +```bash +make logs-simulator +make logs-dc-mock +make logs-api +make logs-calibrator +``` + +## Stop the System + +Stop all running services: + +```bash +make down +``` + +## Clean Up + +Remove all data and volumes to start fresh: + +```bash +make clean-volumes +``` + +This removes simulation results and resets Grafana to its initial state. diff --git a/site/docs/intro.mdx b/site/docs/intro.mdx index 840ae34..2390812 100644 --- a/site/docs/intro.mdx +++ b/site/docs/intro.mdx @@ -1,27 +1,65 @@ --- sidebar_position: 1 +slug: / --- # Introduction -OpenDC is a free and open-source platform for cloud datacenter simulation aimed at both research and education. +OpenDT is a **Shadow Mode** Digital Twin for datacenters. It connects to a real datacenter (or a mock of one) and replays workload data through the [OpenDC](https://opendc.org) simulator, comparing predicted power consumption against actual measurements in real time. -
-
-
- Constructing a cloud datacenter with OpenDC -
-
- Analysis of results reported by OpenDC -
-
-
+## Key Capabilities -Users can construct new datacenter designs and define portfolios of scenarios (experiments) to explore how their designs -perform under different workloads, schedulers, and phenomena (e.g., failures or performance interference). +- **Power Prediction**: Estimate power consumption based on workload patterns +- **What-If Analysis**: Test infrastructure changes without touching live hardware +- **Active Calibration**: Automatically tune simulation parameters to minimize prediction error +- **Carbon Estimation**: Calculate carbon emissions from power draw and grid intensity -OpenDC is accessible both as a ready-to-use platform hosted by us online at [app.opendc.org](https://app.opendc.org), and as -source code that users can run locally on their own machine or via Docker. +## Architecture Overview -To learn more about OpenDC, have a look through our paper on [OpenDC 2.0](https://atlarge-research.com/pdfs/ccgrid21-opendc-paper.pdf) -or on our [vision](https://atlarge-research.com/pdfs/opendc-vision17ispdc_cr.pdf). +OpenDT bridges physical and digital infrastructure through a modular design: + +![OpenDT High-Level Architecture](/img/design_opendt_hl.png) + +| Component | Purpose | OpenDT Implementation | +|-----------|---------|----------------------| +| Physical Infrastructure (P) | Data source | dc-mock service | +| Front-end (B) | User interfaces | Grafana + REST API | +| Orchestration (C) | Message routing | Kafka | +| Data Platform (D, E) | Telemetry & storage | Parquet files | +| Simulator (F-I) | Power prediction | OpenDC + calibrator | +| Virtual Infrastructure (V) | Digital twin state | Topology configuration | + +See the [Architecture](/docs/concepts/architecture) page for detailed component mapping. + +## Data Flow + +1. **dc-mock** reads historical workload and power data from Parquet files +2. Messages are published to **Kafka** topics with configurable speed factor +3. **simulator** consumes workload messages, aggregates them into time windows, and invokes OpenDC +4. **api** queries results and serves them to the **Grafana** dashboard + +## Use Cases + +### Reproduce Experiments + +Run predefined experiments with the Reproducibility Capsule: + +```bash +make up config=config/experiments/experiment_1.yaml +``` + +### Explore What-If Scenarios + +Modify the datacenter topology and observe how power consumption changes: + +- Upgrade CPU architecture +- Change power model parameters +- Add or remove hosts + +### Validate Power Models + +Compare different power model types (MSE, Asymptotic, Linear) against real measurements to find the best fit for your hardware. + +## Quick Start + +See the [Getting Started](/docs/category/getting-started) guide to run your first simulation. diff --git a/site/docs/reproducibility.md b/site/docs/reproducibility.md new file mode 100644 index 0000000..251aa92 --- /dev/null +++ b/site/docs/reproducibility.md @@ -0,0 +1,76 @@ +--- +sidebar_position: 6 +--- + +# Reproducibility Capsule + +The reproducibility capsule provides scripts to reproduce the experiments from the OpenDT paper. + +## Experiments + +| Experiment | Description | Configuration | +|------------|-------------|---------------| +| Experiment 1 | Power prediction without calibration | `experiment_1.yaml` | +| Experiment 2 | Power prediction with active calibration | `experiment_2.yaml` | + +## Running Experiments + +### 1. Start OpenDT + +Run with the experiment configuration: + +```bash +# Experiment 1 (without calibration) +make up config=config/experiments/experiment_1.yaml + +# Experiment 2 (with calibration) +make up config=config/experiments/experiment_2.yaml +``` + +### 2. Wait for Completion + +With `speed_factor: 300` and the SURF workload (~7 days), each experiment takes approximately 1 hour. + +Monitor progress via the Grafana dashboard at http://localhost:3000. + +### 3. Generate Plots + +After the simulation completes: + +```bash +python reproducibility-capsule/generate_plot.py +``` + +The interactive script will: + +1. Prompt you to select an experiment (1 or 2) +2. Show available data sources with timestamps +3. Generate a plot comparing: + - Ground Truth (actual power) + - FootPrinter baseline + - OpenDT prediction + +Plots are saved to `reproducibility-capsule/output/`. + +## Output + +Generated plots show: + +- **Left Y-axis**: Power draw (kW) +- **Right Y-axis**: MAPE percentage +- **X-axis**: Time + +Three time series are plotted: + +- Ground Truth (actual measurements) +- FootPrinter (baseline predictor) +- OpenDT (digital twin prediction) + +MAPE values for both FootPrinter and OpenDT are displayed in the legend. + +## Reference Data + +The `reproducibility-capsule/data/` directory contains reference data for the baseline comparisons: + +- `footprinter.parquet` - FootPrinter predictions +- `real_world.parquet` - Ground truth measurements diff --git a/site/docs/services/_category_.json b/site/docs/services/_category_.json new file mode 100644 index 0000000..ce5567f --- /dev/null +++ b/site/docs/services/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Services", + "position": 5, + "link": { + "type": "generated-index", + "description": "Documentation for each OpenDT service." + } +} diff --git a/site/docs/services/api.md b/site/docs/services/api.md new file mode 100644 index 0000000..24ecf26 --- /dev/null +++ b/site/docs/services/api.md @@ -0,0 +1,66 @@ +--- +sidebar_position: 4 +--- + +# api + +REST API for querying simulation data and controlling the datacenter topology. + +## Access + +| URL | Description | +|-----|-------------| +| http://localhost:3001/docs | API Documentation (Swagger) | +| http://localhost:3001/redoc | API Documentation (ReDoc) | +| http://localhost:3001/health | Health check endpoint | + +## Endpoints + +### GET /health + +Service health status. + +### GET /api/power + +Query aligned power data from simulation and actual consumption. + +**Parameters:** + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| interval_seconds | int | 60 | Sampling interval | +| start_time | datetime | - | Start time filter (optional) | + +**Returns:** Timeseries of `timestamp`, `simulated_power`, `actual_power` + +### GET /api/carbon_emission + +Query carbon emission data based on power draw and grid carbon intensity. + +**Parameters:** + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| interval_seconds | int | 60 | Sampling interval | +| start_time | datetime | - | Start time filter (optional) | + +**Returns:** Timeseries of `timestamp`, `carbon_emission` + +### PUT /api/topology + +Update the simulated datacenter topology. + +Publishes the new topology to `sim.topology` Kafka topic. The simulator will use this topology for future simulations. + +## Data Sources + +The API reads from: + +- `data//simulator/agg_results.parquet` - Simulation results +- `workload//consumption.parquet` - Actual power data + +## Logs + +```bash +make logs-api +``` diff --git a/site/docs/services/calibrator.md b/site/docs/services/calibrator.md new file mode 100644 index 0000000..f78c8bd --- /dev/null +++ b/site/docs/services/calibrator.md @@ -0,0 +1,67 @@ +--- +sidebar_position: 3 +--- + +# calibrator + +Optimizes topology parameters by comparing simulation output against actual power measurements. + +## Purpose + +The calibrator performs grid search over topology parameters to find values that minimize the error between predicted and actual power consumption. It publishes the best-performing topology to Kafka for use by the simulator. + +## How It Works + +1. **Accumulate tasks** - Collect tasks from `dc.workload` +2. **Track power** - Record actual power from `dc.power` +3. **Grid search** - Run parallel simulations with different parameter values +4. **Compare** - Calculate MAPE against actual power +5. **Select** - Choose the parameter value with lowest error +6. **Publish** - Send calibrated topology to `sim.topology` + +## Calibrated Properties + +The calibrator can tune any numeric topology parameter. Common targets: + +| Property | Path | Description | +|----------|------|-------------| +| asymUtil | cpuPowerModel.asymUtil | Asymptotic utilization coefficient | +| calibrationFactor | cpuPowerModel.calibrationFactor | MSE model scaling factor | + +## Components + +| File | Purpose | +|------|---------| +| main.py | Service orchestration | +| calibration_engine.py | Parallel OpenDC simulations | +| mape_comparator.py | Error calculation | +| power_tracker.py | Actual power tracking | +| topology_manager.py | Topology subscription and publishing | + +## Configuration + +Enable calibration in your config file: + +```yaml +global: + calibration_enabled: true +``` + +The calibrator only runs when this flag is set. + +## Output + +Results are written to `data//calibrator/`: + +``` +calibrator/ +โ”œโ”€โ”€ agg_results.parquet +โ””โ”€โ”€ opendc/ + โ””โ”€โ”€ run_/ +``` + +## Logs + +```bash +make logs-calibrator +``` diff --git a/site/docs/services/dc-mock.md b/site/docs/services/dc-mock.md new file mode 100644 index 0000000..a61af89 --- /dev/null +++ b/site/docs/services/dc-mock.md @@ -0,0 +1,54 @@ +--- +sidebar_position: 1 +--- + +# dc-mock + +Simulates a datacenter by replaying historical workload and power data to Kafka. + +## Purpose + +dc-mock acts as the data source for OpenDT. It reads Parquet files from the configured workload directory and publishes messages to Kafka topics, respecting the configured speed factor. + +## Data Sources + +Reads from `workload//`: + +| File | Topic | Description | +|------|-------|-------------| +| tasks.parquet + fragments.parquet | dc.workload | Task submissions with execution profiles | +| consumption.parquet | dc.power | Power consumption telemetry | +| topology.json | dc.topology | Datacenter hardware configuration | + +## Message Types + +### Workload Messages + +Published to `dc.workload`. Two types: + +**Task message**: Contains task data for the simulator to process. + +**Heartbeat message**: Signals time progression, enabling window closing even during sparse workload periods. + +### Power Messages + +Published to `dc.power` with actual power measurements from the workload. + +### Topology Messages + +Published to `dc.topology` (compacted topic) with the datacenter hardware configuration. + +## Configuration + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| workload | string | "SURF" | Workload directory under `workload/` | +| heartbeat_frequency_minutes | int | 1 | Heartbeat interval in simulation time | + +Lower heartbeat frequency provides more granular window closing but increases Kafka message volume. + +## Logs + +```bash +make logs-dc-mock +``` diff --git a/site/docs/services/grafana.md b/site/docs/services/grafana.md new file mode 100644 index 0000000..c255e31 --- /dev/null +++ b/site/docs/services/grafana.md @@ -0,0 +1,52 @@ +--- +sidebar_position: 5 +--- + +# grafana + +Visualization dashboards for OpenDT metrics. + +## Access + +| URL | Description | +|-----|-------------| +| http://localhost:3000 | Grafana Dashboard | + +Authentication is disabled by default (anonymous admin access). + +## Dashboards + +### Power Dashboard + +The default dashboard displays: + +- **Power Consumption** - Actual vs simulated power draw over time +- **Carbon Emissions** - Estimated carbon emissions based on power and grid intensity + +Data is queried from the OpenDT API at `http://api:8000`. + +## Provisioning + +Dashboards in `services/grafana/provisioning/dashboards/` are auto-provisioned on startup. These are read-only templates. + +To create an editable copy: + +1. Open the dashboard +2. Click title โ†’ **Save As** +3. Save with a new name + +Custom dashboards are persisted in the Docker volume. + +## Data Sources + +The [Infinity datasource](https://grafana.com/grafana/plugins/yesoreyeram-infinity-datasource/) is pre-configured to query JSON endpoints from the OpenDT API. + +## Persistence + +Dashboard data is stored in the `opendt-grafana-storage` Docker volume. + +To reset Grafana to its initial state: + +```bash +make clean-volumes +``` diff --git a/site/docs/services/kafka.md b/site/docs/services/kafka.md new file mode 100644 index 0000000..934601a --- /dev/null +++ b/site/docs/services/kafka.md @@ -0,0 +1,53 @@ +--- +sidebar_position: 6 +--- + +# kafka + +Message broker for inter-service communication. + +## Topics + +| Topic | Type | Description | +|-------|------|-------------| +| dc.workload | Stream | Task submissions and heartbeats | +| dc.power | Stream | Power consumption telemetry | +| dc.topology | Compacted | Real datacenter topology | +| sim.topology | Compacted | Calibrated topology | +| sim.results | Stream | Simulation predictions | +| sys.config | Compacted | Runtime configuration | + +## Topic Types + +**Stream topics** retain all messages for a configured duration. Used for event data that should be processed sequentially. + +**Compacted topics** keep only the latest value per key. Used for state data like topology and configuration. + +## kafka-init + +The `kafka-init` container creates and configures topics before other services start. It runs once and exits, blocking dependent services until topics are ready. + +## Configuration + +Topics are configured under `kafka.topics` in the config file: + +```yaml +kafka: + topics: + workload: + name: "dc.workload" + config: + retention.ms: "86400000" + topology: + name: "dc.topology" + config: + cleanup.policy: "compact" +``` + +### Topic Configuration Options + +| Option | Description | +|--------|-------------| +| retention.ms | How long messages are retained (milliseconds) | +| cleanup.policy | "delete" (time-based) or "compact" (key-based) | +| min.compaction.lag.ms | Minimum time before compaction | diff --git a/site/docs/services/simulator.md b/site/docs/services/simulator.md new file mode 100644 index 0000000..08f7ad4 --- /dev/null +++ b/site/docs/services/simulator.md @@ -0,0 +1,80 @@ +--- +sidebar_position: 2 +--- + +# simulator + +Core simulation engine that consumes workload data and produces power predictions using OpenDC. + +## Purpose + +The simulator aggregates tasks into time windows, invokes the OpenDC simulator, and outputs power consumption predictions. It maintains cumulative state to ensure accurate long-running simulations. + +## Processing Flow + +1. **Consume** - Read tasks from `dc.workload` topic +2. **Aggregate** - Group tasks into time windows +3. **Close** - When heartbeat timestamp exceeds window end +4. **Simulate** - Invoke OpenDC with all tasks from beginning +5. **Output** - Write results to `agg_results.parquet` + +## Cumulative Simulation + +Each window simulates all tasks from the beginning of the workload, not just tasks in that window. This ensures accurate power predictions for long-running workloads. + +## OpenDC Integration + +The simulator invokes the OpenDC binary for power calculations: + +``` +opendc/bin/OpenDCExperimentRunner +``` + +### Input Files + +Created for each simulation run: + +- `experiment.json` - OpenDC configuration +- `topology.json` - Datacenter topology +- `tasks.parquet` - Task definitions +- `fragments.parquet` - Task execution profiles + +### Output Files + +Parsed after simulation: + +- `powerSource.parquet` - Power consumption over time +- `host.parquet` - Host-level metrics + +## Output + +Results are written to `data//simulator/`: + +``` +simulator/ +โ”œโ”€โ”€ agg_results.parquet +โ””โ”€โ”€ opendc/ + โ””โ”€โ”€ run_/ +``` + +### agg_results.parquet + +| Column | Description | +|--------|-------------| +| timestamp | Simulation timestamp | +| power_draw | Predicted power in Watts | +| carbon_intensity | Grid carbon intensity | + +## Configuration + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| simulation_frequency_minutes | int | 5 | Window size for task aggregation | + +Larger windows mean fewer OpenDC invocations but less granular results. + +## Logs + +```bash +make logs-simulator +``` diff --git a/site/docs/tutorials/1. Simple Experiment/1.1 Running.md b/site/docs/tutorials/1. Simple Experiment/1.1 Running.md deleted file mode 100644 index dd5e6e6..0000000 --- a/site/docs/tutorials/1. Simple Experiment/1.1 Running.md +++ /dev/null @@ -1,335 +0,0 @@ -# Running an Experiment -Now that you have downloaded OpenDC, we will start creating a simple experiment. -In this experiment we will compare the performance of a small, and a big data center on the same workload. - -:::info -In this tutorial, we will learn how to create and execute a simple experiment in OpenDC. - -This is tutorial is based on a "Simple demo" which can be found on the OpenDC [github](https://github.com/atlarge-research/opendc). - -Download the demo [here] to run in an interactive notebook. -::: - -Running this demo requires OpenDC. Download the latest release [here](https://github.com/atlarge-research/opendc/releases) and put it in this folder. - -## 1. Designing a Data Center - -The first requirement to run an experiment in OpenDC is a `topology`. -A `topology` defines the hardware on which a `workload` is executed. -Larger topologies will be capable of running more workloads, and will often quicker. - -A `topology` is defined using a JSON file. A `topology` contains one or more _clusters_. -_clusters_ are groups of _hosts_ on a specific location. Each cluster consists of one or more _hosts_. -A _host_ is a machine on which one or more tasks can be executed. _hosts_ are composed of a _cpu_ and a _memory_ unit. - - -### Small Data Center -in this experiment, we are comparing two data centers. Below is an example of the small `topology` file: - -```json -{ - "clusters": - [ - { - "name": "C01", - "hosts" : - [ - { - "name": "H01", - "cpu": - { - "coreCount": 12, - "coreSpeed": 3300 - }, - "memory": { - "memorySize": 140457600000 - } - } - ] - } - ] -} -``` - -This `topology` consist of a single _cluster_, with a single _host_. - -The `topology` file can be found [here](topologies/small_datacenter.json) - -### Large Data Center -We compare the the previous datacenter with a larger datacenter defined by the following `topology` file: - -```json -{ - "clusters": - [ - { - "name": "C01", - "hosts" : - [ - { - "name": "H01", - "cpu": - { - "coreCount": 32, - "coreSpeed": 3200 - }, - "memory": { - "memorySize": 256000 - } - } - ] - }, - { - "name": "C02", - "hosts" : - [ - { - "name": "H02", - "count": 6, - "cpu": - { - "coreCount": 8, - "coreSpeed": 2930 - }, - "memory": { - "memorySize": 64000 - } - } - ] - }, - { - "name": "C03", - "hosts" : - [ - { - "name": "H03", - "count": 2, - "cpu": - { - "coreCount": 16, - "coreSpeed": 3200 - }, - "memory": { - "memorySize": 128000 - } - } - ] - } - ] -} -``` - -Compared to the small topology, the big topology consist of three clusters, all consisting of a single host. - -The `topology` file can be found [here](topologies/big_datacenter.json) - -:::tip -For more in depth information about Topologies, see [Topology](https://atlarge-research.github.io/opendc/docs/documentation/Input/Topology/) -::: - -## 2. Workloads - -Next to the topology, we need a workload to simulate on the data center. -In OpenDC, workloads are defined as a bag of tasks. Each task is accompanied by one or more fragments. -These fragments define the computational requirements of the task over time. -For this experiment, we will use the bitbrains-small workload. This is a small workload of 50 tasks, -spanning over a bit more than a month time. - -Workloads traces define when tasks are submitted, and their computational requirements. -A workload consists of two trace files defined as parquet files: - -- `tasks.parquet` provides a general overview of the tasks executed during the workload. It defines when tasks are scheduled and the hardware they require. -- `fragments.parquet` provides detailed information of each task during its runtime - -
- -###### Input -```python -import pandas as pd - -df_tasks = pd.read_parquet("workload_traces/bitbrains-small/tasks.parquet") -df_fragments = pd.read_parquet("workload_traces/bitbrains-small/fragments.parquet") - -df_tasks.head() -``` ---- -###### Output -| | id | submission_time | duration | cpu_count | cpu_capacity | mem_capacity | -|---:|-----:|:--------------------|-----------:|------------:|---------------:|---------------:| -| 0 | 1019 | 2013-08-12 13:35:46 | 2592252000 | 1 | 2926 | 181352 | -| 1 | 1023 | 2013-08-12 13:35:46 | 2592252000 | 1 | 2926 | 260096 | -| 2 | 1026 | 2013-08-12 13:35:46 | 2592252000 | 1 | 2926 | 249972 | -| 3 | 1052 | 2013-08-29 14:38:12 | 577855000 | 1 | 2926 | 131245 | -| 4 | 1073 | 2013-08-21 11:07:12 | 1823566000 | 1 | 2600 | 179306 | - -
- -
- -###### Input -```python -df_fragments.head() -``` ---- -###### Output -| | id | duration | cpu_count | cpu_usage | -|---:|-----:|-----------:|------------:|------------:| -| 0 | 1019 | 300000 | 1 | 0 | -| 1 | 1019 | 300000 | 1 | 11.704 | -| 2 | 1019 | 600000 | 1 | 0 | -| 3 | 1019 | 300000 | 1 | 11.704 | -| 4 | 1019 | 900000 | 1 | 0 | - -
- -## 3. Executing an experiment - -To run an experiment, we need to create an `experiment` file. This is a JSON file, that defines what should be executed -by OpenDC, and how. Below is an example of a simple `experiment` file: - -```json -{ - "name": "simple", - "topologies": [ - { - "pathToFile": "topologies/small_datacenter.json" - }, - { - "pathToFile": "topologies/big_datacenter.json" - } - ], - "workloads": [ - { - "pathToFile": "workload_traces/bitbrains-small", - "type": "ComputeWorkload" - } - ], - "exportModels": [ - { - "exportInterval": 3600, - "printFrequency": 168, - "filesToExport": [ - "host", - "powerSource", - "service", - "task" - ] - } - ] -} -``` - -The **experiment** file defines four parameter values. First, is the `name`. This defines how the experiment is called in the output folder. Second, is the `topologies`. This defines where OpenDC can find the topology files. -third, the `workloads`. This defines which workload OpenDC should run. Finally, `exportModels` defines how OpenDC should export its result. -In this case we set the `exportInterval` and the `printFrequency`, and the `filesToExport`. -The `exportInterval` and the `printFrequency` determine how often OpenDC should sample for output, and print to the terminal. -Using `filesToExport` we specify that we only want to output specific files. - -As you can see, both `topolgies` and `workloads` are defined as lists. This allows the user to define multiple values. OpenDC will run a simulation for each seperate combination of parameter values. In this case two simulations will be ran; one with the small topology, and one with the big topology. - -:::tip -For more in depth information about ExportModels, see [ExportModel](https://atlarge-research.github.io/opendc/docs/documentation/Input/ExportModel). - -For more in depth information about Experiments, see [Experiment](https://atlarge-research.github.io/opendc/docs/documentation/Input/Experiment) -::: - -## 4. Running OpenDC - -An experiment in OpenDC can be executed directly from the terminal. The only parameter that needs to be provided is `--experiment-path` which is the path to the `experiment` file we defined in 3. While running the experiment, OpenDC periodically prints information about the status of the simulation. In this experiment, OpenDC prints every week, but this can be changes using the `exportModel`. - -
- -###### Input -```python -import subprocess - -pathToScenario = "experiments/simple_experiment.json" -subprocess.run(["OpenDCExperimentRunner/bin/OpenDCExperimentRunner", "--experiment-path", pathToScenario]) -``` ---- -###### Output -
- - - -================================================================================ - Running scenario: 0  -================================================================================ - Starting seed: 0  - -Simulating... 0% [ ] 0/2 (0:00:00 / ?) -12:54:19.045 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 1680 hours: - Tasks Total: 50 - Tasks Active: 1 - Tasks Pending: 39 - Tasks Completed: 10 - Tasks Terminated: 0 - -12:54:19.269 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 3360 hours: - Tasks Total: 50 - Tasks Active: 1 - Tasks Pending: 37 - Tasks Completed: 12 - Tasks Terminated: 0 - -12:54:19.471 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 5040 hours: - Tasks Total: 50 - Tasks Active: 3 - Tasks Pending: 32 - Tasks Completed: 15 - Tasks Terminated: 0 - -12:54:19.724 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 6720 hours: - Tasks Total: 50 - Tasks Active: 3 - Tasks Pending: 26 - Tasks Completed: 21 - Tasks Terminated: 0 - -12:54:19.883 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 8400 hours: - Tasks Total: 50 - Tasks Active: 2 - Tasks Pending: 18 - Tasks Completed: 30 - Tasks Terminated: 0 - -12:54:19.913 [WARN ] org.opendc.compute.simulator.service.ComputeService - Failed to spawn Task[uid=00000000-0000-0000-8c8a-1f148a8bb259,name=740,state=PROVISIONING]: does not fit -12:54:19.979 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 10080 hours: - Tasks Total: 50 - Tasks Active: 5 - Tasks Pending: 8 - Tasks Completed: 36 - Tasks Terminated: 1 - -12:54:20.043 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 11760 hours: - Tasks Total: 50 - Tasks Active: 3 - Tasks Pending: 0 - Tasks Completed: 46 - Tasks Terminated: 1 - - - -================================================================================ - Running scenario: 1  -================================================================================ - Starting seed: 0  - -Simulating... 100% [=================================] 2/2 (0:00:03 / 0:00:00) - -
- -``` -CompletedProcess(args=['OpenDCExperimentRunner/bin/OpenDCExperimentRunner', '--experiment-path', 'experiments/simple_experiment.json'], returncode=0) -``` -
- -Running the simulation has created the `output` folder containing information about the experiment. -In the next tutorial we will use these files for analysis and visualization. diff --git a/site/docs/tutorials/1. Simple Experiment/1.2 Analysis.md b/site/docs/tutorials/1. Simple Experiment/1.2 Analysis.md deleted file mode 100644 index e14c29f..0000000 --- a/site/docs/tutorials/1. Simple Experiment/1.2 Analysis.md +++ /dev/null @@ -1,401 +0,0 @@ -# Analysis and Vizualization - -The next step is to analyse the results created by OpenDC. -When running a simulation, OpenDC generates five types of output files. -The output folder has the following structure: - -``` -[1] output ๐Ÿ“ -[2] โ”œโ”€โ”€ simple ๐Ÿ“ -[3] โ”‚ โ”œโ”€โ”€ raw-output ๐Ÿ“ -[4] โ”‚ โ”‚ |โ”€โ”€ 0 ๐Ÿ“ -[5] โ”‚ โ”‚ | โ””โ”€โ”€ seed=0 ๐Ÿ“ -[6] | | | โ””โ”€โ”€ host.parquet ๐Ÿ“„ -[7] | | | โ””โ”€โ”€ powerSource.parquet ๐Ÿ“„ -[8] | | | โ””โ”€โ”€ service.parquet ๐Ÿ“„ -[9] | | | โ””โ”€โ”€ task.parquet ๐Ÿ“„ -[10] โ”‚ โ”‚ |โ”€โ”€ 1 ๐Ÿ“ -[11] โ”‚ โ”‚ | โ””โ”€โ”€ seed=0 ๐Ÿ“ -[12] | | | โ””โ”€โ”€ host.parquet ๐Ÿ“„ -[13] | | | โ””โ”€โ”€ powerSource.parquet ๐Ÿ“„ -[14] | | | โ””โ”€โ”€ service.parquet ๐Ÿ“„ -[15] | | | โ””โ”€โ”€ task.parquet ๐Ÿ“„ -[16] | โ”œโ”€โ”€ simulation-analysis ๐Ÿ“ -[17] | โ””โ”€โ”€ trackr.json ๐Ÿ“„ -``` - -The output of an experiment is put inside the `output` folder, in a folder with the given experiment name. -The output of the experiment executed in the previous part of this tutorial can be found in the folder named "simple"[2]. - -The output folder of an experiment consist of one file and two folders: -- `raw-output` contains all raw output files generated during the simulation -- `simulation-analysis` contains automatic analysis done by OpenDC **Note**: this is only relevant when using M3SA and can be ignored in this tutorial -- `trackr.json` contains the parameters used in the simulations executed during the experiment. This file can be very helpful when running a large number of experiments - -## 5. raw-output - -The `raw-output` folder is subdivided into multiple folders, each representing a single simulation run by OpenDC. As we explained in the first part of the tutorial, users can provide multiple value for certain parameters. Consequently, OpenDC will run a simulation for each combination of parameters. - -In this tutorial, we have run two simulations resulting in two folders. The `trackr` file can be used to determine which output is related to which configuration. Each simulation is divided into multiple folders depending on how many times the simulation is run. Using the `runs` parameter in the `experiment` files, a user can execute the same simulation multiple times. Each run is executed using a different random seed. This can be usefull when a simulation uses random models. - -During a simulation, multiple parquet files are created with information regarding different aspects. We advise users to use the Pandas library for reading the output files. This will make analysis and vizualization easy. - -
- -###### Input -```python -import pandas as pd -import numpy as np -import matplotlib.pyplot as plt - -df_host_small = pd.read_parquet("output/simple/raw-output/0/seed=0/host.parquet") -df_powerSource_small = pd.read_parquet("output/simple/raw-output/0/seed=0/powerSource.parquet") -df_task_small = pd.read_parquet("output/simple/raw-output/0/seed=0/task.parquet") -df_service_small = pd.read_parquet("output/simple/raw-output/0/seed=0/service.parquet") - -df_host_big = pd.read_parquet("output/simple/raw-output/1/seed=0/host.parquet") -df_powerSource_big = pd.read_parquet("output/simple/raw-output/1/seed=0/powerSource.parquet") -df_task_big = pd.read_parquet("output/simple/raw-output/1/seed=0/task.parquet") -df_service_big = pd.read_parquet("output/simple/raw-output/1/seed=0/service.parquet") - -print(f"The small dataset has {len(df_service_small)} service samples.") -print(f"The big dataset has {len(df_service_big)} service samples.") -``` ---- -###### Output -
- -The small dataset has 12026 service samples. -The big dataset has 1440 service samples. - -
- -
- -### Host - -The host file contains all metrics regarding the hosts. - -Examples of how to use this information: -- How much power is each host drawing? -- What is the average utilization of hosts? - -
- -###### Input -```python -print(f"The host file contains the following columns:\n {np.array(df_host_small.columns)}\n") -print(f"The host file consist of {len(df_host_small)} samples\n") -df_host_small.head() -``` ---- -###### Output -
- -The host file contains the following columns: - ['timestamp' 'timestamp_absolute' 'host_name' 'cluster_name' 'core_count' - 'mem_capacity' 'tasks_terminated' 'tasks_running' 'tasks_error' - 'tasks_invalid' 'cpu_capacity' 'cpu_usage' 'cpu_demand' 'cpu_utilization' - 'cpu_time_active' 'cpu_time_idle' 'cpu_time_steal' 'cpu_time_lost' - 'power_draw' 'energy_usage' 'embodied_carbon' 'uptime' 'downtime' - 'boot_time'] - -The host file consist of 12026 samples - - -
- -| | timestamp | timestamp_absolute | host_name | cluster_name | core_count | mem_capacity | tasks_terminated | tasks_running | tasks_error | tasks_invalid | ... | cpu_time_active | cpu_time_idle | cpu_time_steal | cpu_time_lost | power_draw | energy_usage | embodied_carbon | uptime | downtime | boot_time | -|---:|------------:|---------------------:|:------------|:---------------|-------------:|---------------:|-------------------:|----------------:|--------------:|----------------:|:------|------------------:|----------------:|-----------------:|----------------:|-------------:|---------------:|------------------:|---------:|-----------:|--------------:| -| 0 | 3600000 | 1376318146000 | H01 | C01 | 12 | 140457600000 | 0 | 8 | 0 | 0 | ... | 20173 | 3579827 | 0 | 0 | 201.169 | 724034 | 22.8311 | 3600000 | 0 | 1376314546000 | -| 1 | 7200000 | 1376321746000 | H01 | C01 | 12 | 140457600000 | 0 | 8 | 0 | 0 | ... | 20804 | 3579196 | 0 | 0 | 201.326 | 724161 | 22.8311 | 3600000 | 0 | 1376314546000 | -| 2 | 10800000 | 1376325346000 | H01 | C01 | 12 | 140457600000 | 0 | 8 | 0 | 0 | ... | 20156 | 3579844 | 0 | 0 | 201.158 | 724031 | 22.8311 | 3600000 | 0 | 1376314546000 | -| 3 | 14400000 | 1376328946000 | H01 | C01 | 12 | 140457600000 | 0 | 8 | 0 | 0 | ... | 20457 | 3579543 | 0 | 0 | 201.376 | 724091 | 22.8311 | 3600000 | 0 | 1376314546000 | -| 4 | 18000000 | 1376332546000 | H01 | C01 | 12 | 140457600000 | 0 | 8 | 0 | 0 | ... | 20021 | 3579979 | 0 | 0 | 201.159 | 724004 | 22.8311 | 3600000 | 0 | 1376314546000 | - -
- -### Tasks -The host file contains all metrics regarding the executed tasks. - -Examples of how to use this information: -- When is a specific task executed? -- How long did it take to finish a task? -- On which host was a task executed? - -
- -###### Input -```python -print(f"The task file contains the following columns:\n {np.array(df_task_small.columns)}\n") -print(f"The task file consist of {len(df_task_small)} samples\n") -df_task_small.head() -``` ---- -###### Output -
- -The task file contains the following columns: - ['timestamp' 'timestamp_absolute' 'task_id' 'task_name' 'host_name' - 'mem_capacity' 'cpu_count' 'cpu_limit' 'cpu_usage' 'cpu_demand' - 'cpu_time_active' 'cpu_time_idle' 'cpu_time_steal' 'cpu_time_lost' - 'uptime' 'downtime' 'num_failures' 'num_pauses' 'schedule_time' - 'submission_time' 'finish_time' 'task_state'] - -The task file consist of 343430 samples - - -
- -| | timestamp | timestamp_absolute | task_id | task_name | host_name | mem_capacity | cpu_count | cpu_limit | cpu_usage | cpu_demand | ... | cpu_time_steal | cpu_time_lost | uptime | downtime | num_failures | num_pauses | schedule_time | submission_time | finish_time | task_state | -|---:|------------:|---------------------:|:-------------------------------------|------------:|:------------|---------------:|------------:|------------:|------------:|-------------:|:------|-----------------:|----------------:|---------:|-----------:|---------------:|-------------:|----------------:|------------------:|--------------:|:-------------| -| 0 | 3600000 | 1376318146000 | 00000000-0000-0000-5449-6ad67bd2634c | 205 | | 20972 | 8 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | nan | 0 | nan | PROVISIONING | -| 1 | 3600000 | 1376318146000 | 00000000-0000-0000-f88b-b8a8724c81ec | 1023 | H01 | 260 | 1 | 0 | 11.704 | 11.704 | ... | 0 | 0 | 3600000 | 0 | 0 | 0 | 0 | 0 | nan | RUNNING | -| 2 | 3600000 | 1376318146000 | 00000000-0000-0000-6963-0f7593d108c3 | 378 | | 8360 | 2 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | nan | 0 | nan | PROVISIONING | -| 3 | 3600000 | 1376318146000 | 00000000-0000-0000-12c0-15a97019e937 | 466 | | 3142 | 4 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | nan | 0 | nan | PROVISIONING | -| 4 | 3600000 | 1376318146000 | 00000000-0000-0000-451e-521296a7eea1 | 915 | | 262 | 1 | 0 | 0 | 0 | ... | 0 | 0 | 0 | 0 | 0 | 0 | nan | 0 | nan | PROVISIONING | - -
- -### PowerSource -The task file contains all information about the power sources. -Examples of use cases: -- What is the total energy used during the workload? - -
- -###### Input -```python -print(f"The task file contains the following columns:\n {np.array(df_powerSource_small.columns)}\n") -print(f"The power file consist of {len(df_powerSource_small)} samples\n") -df_powerSource_small.head() -``` ---- -###### Output -
- -The task file contains the following columns: - ['timestamp' 'timestamp_absolute' 'source_name' 'cluster_name' - 'power_draw' 'energy_usage' 'carbon_intensity' 'carbon_emission'] - -The power file consist of 12026 samples - - -
- -| | timestamp | timestamp_absolute | source_name | cluster_name | power_draw | energy_usage | carbon_intensity | carbon_emission | -|---:|------------:|---------------------:|:--------------|:---------------|-------------:|---------------:|-------------------:|------------------:| -| 0 | 3600000 | 1376318146000 | PowerSource | C01 | 201.169 | 724034 | 0 | 0 | -| 1 | 7200000 | 1376321746000 | PowerSource | C01 | 201.326 | 724161 | 0 | 0 | -| 2 | 10800000 | 1376325346000 | PowerSource | C01 | 201.158 | 724031 | 0 | 0 | -| 3 | 14400000 | 1376328946000 | PowerSource | C01 | 201.376 | 724091 | 0 | 0 | -| 4 | 18000000 | 1376332546000 | PowerSource | C01 | 201.159 | 724004 | 0 | 0 | - -
- -### Service - -The service file contains genaral information about the experiments. - -Example use cases of this file: -- How many tasks are running? -- How many hosts are active? - -
- -###### Input -```python -print(f"The service file contains the following columns:\n {np.array(df_service_small.columns)}\n") -print(f"The service file consist of {len(df_service_small)} samples\n") -df_service_small.head() -``` ---- -###### Output -
- -The service file contains the following columns: - ['timestamp' 'timestamp_absolute' 'hosts_up' 'hosts_down' 'tasks_total' - 'tasks_pending' 'tasks_active' 'tasks_completed' 'tasks_terminated'] - -The service file consist of 12026 samples - - -
- -| | timestamp | timestamp_absolute | hosts_up | hosts_down | tasks_total | tasks_pending | tasks_active | tasks_completed | tasks_terminated | -|---:|------------:|---------------------:|-----------:|-------------:|--------------:|----------------:|---------------:|------------------:|-------------------:| -| 0 | 3600000 | 1376318146000 | 1 | 0 | 44 | 36 | 8 | 0 | 0 | -| 1 | 7200000 | 1376321746000 | 1 | 0 | 44 | 36 | 8 | 0 | 0 | -| 2 | 10800000 | 1376325346000 | 1 | 0 | 44 | 36 | 8 | 0 | 0 | -| 3 | 14400000 | 1376328946000 | 1 | 0 | 44 | 36 | 8 | 0 | 0 | -| 4 | 18000000 | 1376332546000 | 1 | 0 | 44 | 36 | 8 | 0 | 0 | - -
- -## 6. Aggregating Ouput - -To get a better understanding of the experiment output, we would like to aggregate them into single values. -This will also allow us to determine the differences between the two datacenter sizes. - -We want to compare the two simulations in the following three aspects: -- `runtime` -- `average utilization` -- `energy usage` - -### Runtime - -
- -###### Input -```python -# Getting the end time of the simulation, and thus the runtime -runtime_small = df_service_small["timestamp"].max() -runtime_big = df_service_big["timestamp"].max() - -# Converting the runtime from ms to timedelta -runtime_small_td = pd.to_timedelta(runtime_small, unit='ms') -runtime_big_td = pd.to_timedelta(runtime_big, unit='ms') - -# Printing the results -print(f"The runtime of the small datacenter is {runtime_small_td} hours") -print(f"The runtime of the big datacenter is {runtime_big_td} hours") -``` ---- -###### Output -
- -The runtime of the small dataset is 501 days 01:53:08 hours -The runtime of the big dataset is 59 days 23:44:48 hours - -
- -
- -Using a larger datacenter allows for paralel execution of tasks, and thus a much smaller runtime. - -### Power Draw - -
- -###### Input -```python -# Getting the end time of the simulation, and thus the runtime -runtime_small = df_host_small["cpu_utilization"].mean() -runtime_big = df_host_big["cpu_utilization"].mean() - -# Convert utlization to percentage -runtime_small = runtime_small * 100 -runtime_big = runtime_big * 100 - -# Printing the results -print(f"The average CPU utilization of the small datacenter is {runtime_small:.2f} %") -print(f"The average CPU utilization of the big datacenter is {runtime_big:.2f} %") -``` ---- -###### Output -
- -The average CPU utilization of the small datacenter is 11.84 % -The average CPU utilization of the big datacenter is 10.96 % - -
- -
- -As expected, the average utilization of the big datacenter is lower than that of the smaller datacenter. - -### Energy Usage - -
- -###### Input -```python -# Get the total energy usage during the simulation -energy_small = df_powerSource_small["energy_usage"].sum() -energy_big = df_powerSource_big["energy_usage"].sum() - -# Convert energy to kWh -energy_small = energy_small / 3_600_000 -energy_big = energy_big / 3_600_000 - -# Printing the results -print(f"The total energy usage of the small datacenter is {energy_small:.2f} kWh") -print(f"The total energy usage of the big datacenter is {energy_big:.2f} kWh") -``` ---- -###### Output -
- -The total energy usage of the small datacenter is 2690.83 kWh -The total energy usage of the big datacenter is 2876.43 kWh - -
- -
- -Surprisingly, the big data center uses more energy than the small datacenter, eventhough its runtime is almost 10 times smaller. This is caused by the high number of hosts still drawing power, even when idle. - -## 7. Vizualization - -To getting even more insight into the results, we can plot the results. - - -### Active tasks -First we plot the number of active tasks during the workload - -
- -###### Input -```python -plt.plot(df_service_small["timestamp"]/3_600_000 / 24, df_service_small["tasks_active"], label="Small datacenter") -plt.plot(df_service_big["timestamp"]/3_600_000 / 24, df_service_big["tasks_active"], label="Big datacenter") -plt.xlabel("Time [days]") -plt.ylabel("Number of active tasks") -plt.title("Number of active tasks over time") -plt.legend() -``` ---- -###### Output -``` - -``` -![Figure](figures/1.2_Analysis/figure_124.png) - -
- -Because of its size, the big datacenter is able to run up to 30 tasks in parallel. In contrast, the small datacenter cannot run more than 8 tasks. - -### Energy Usage - -Below we show the energy usage over time for the two data centers. - -
- -###### Input -```python -# Sum the energy usage of each power source at each timestamp -energy_usage_big = df_powerSource_big.groupby("timestamp")["energy_usage"].sum() - -# Compute a windowed (rolling) average of energy usage with a window size of 50 samples -window_size = 100 -energy_usage_small_rolling = df_powerSource_small["energy_usage"].rolling(window=window_size, min_periods=1).mean() -energy_usage_big_rolling = energy_usage_big.rolling(window=window_size, min_periods=1).mean() - -plt.plot(df_powerSource_small["timestamp"] / 3_600_000 / 24, energy_usage_small_rolling / 3_600_000, label="Small datacenter") -plt.plot(energy_usage_big_rolling.index / 3_600_000 / 24, energy_usage_big_rolling / 3_600_000, label="Big datacenter") -plt.xlabel("Time [days]") -plt.ylabel("Energy usage [kWh]") -plt.title("Energy usage of the datacenters") -plt.ylim([0, None]) -plt.legend() -``` ---- -###### Output -``` - -``` -![Figure](figures/1.2_Analysis/figure_141.png) - -
- -Because of its size, the big datacenter is using a lot more energy compared to the small datacenter. diff --git a/site/docs/tutorials/1. Simple Experiment/experiments/simple_experiment.json b/site/docs/tutorials/1. Simple Experiment/experiments/simple_experiment.json deleted file mode 100644 index bab46af..0000000 --- a/site/docs/tutorials/1. Simple Experiment/experiments/simple_experiment.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "simple", - "topologies": [ - { - "pathToFile": "topologies/small_datacenter.json" - }, - { - "pathToFile": "topologies/big_datacenter.json" - } - ], - "workloads": [ - { - "pathToFile": "workload_traces/bitbrains-small", - "type": "ComputeWorkload" - } - ], - "exportModels": [ - { - "exportInterval": 3600, - "printFrequency": 1680, - "filesToExport": [ - "host", - "powerSource", - "service", - "task" - ] - } - ] -} \ No newline at end of file diff --git a/site/docs/tutorials/1. Simple Experiment/figures/1.2_Analysis/figure_124.png b/site/docs/tutorials/1. Simple Experiment/figures/1.2_Analysis/figure_124.png deleted file mode 100644 index bbb30bf..0000000 Binary files a/site/docs/tutorials/1. Simple Experiment/figures/1.2_Analysis/figure_124.png and /dev/null differ diff --git a/site/docs/tutorials/1. Simple Experiment/figures/1.2_Analysis/figure_141.png b/site/docs/tutorials/1. Simple Experiment/figures/1.2_Analysis/figure_141.png deleted file mode 100644 index 31e7c28..0000000 Binary files a/site/docs/tutorials/1. Simple Experiment/figures/1.2_Analysis/figure_141.png and /dev/null differ diff --git a/site/docs/tutorials/1. Simple Experiment/topologies/big_datacenter.json b/site/docs/tutorials/1. Simple Experiment/topologies/big_datacenter.json deleted file mode 100644 index c3a060c..0000000 --- a/site/docs/tutorials/1. Simple Experiment/topologies/big_datacenter.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "clusters": - [ - { - "name": "C01", - "hosts" : - [ - { - "name": "H01", - "cpu": - { - "coreCount": 32, - "coreSpeed": 3200 - }, - "memory": { - "memorySize": 256000 - } - } - ] - }, - { - "name": "C02", - "hosts" : - [ - { - "name": "H02", - "count": 6, - "cpu": - { - "coreCount": 8, - "coreSpeed": 2930 - }, - "memory": { - "memorySize": 64000 - } - } - ] - }, - { - "name": "C03", - "hosts" : - [ - { - "name": "H03", - "count": 2, - "cpu": - { - "coreCount": 16, - "coreSpeed": 3200 - }, - "memory": { - "memorySize": 128000 - } - } - ] - } - ] -} - diff --git a/site/docs/tutorials/1. Simple Experiment/topologies/small_datacenter.json b/site/docs/tutorials/1. Simple Experiment/topologies/small_datacenter.json deleted file mode 100644 index 54e3c6f..0000000 --- a/site/docs/tutorials/1. Simple Experiment/topologies/small_datacenter.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "clusters": - [ - { - "name": "C01", - "hosts" : - [ - { - "name": "H01", - "cpu": - { - "coreCount": 12, - "coreSpeed": 3300 - }, - "memory": { - "memorySize": 140457600000 - } - } - ] - } - ] -} diff --git a/site/docs/tutorials/2. Carbon Emission/2.1 Running.md b/site/docs/tutorials/2. Carbon Emission/2.1 Running.md deleted file mode 100644 index 7171a81..0000000 --- a/site/docs/tutorials/2. Carbon Emission/2.1 Running.md +++ /dev/null @@ -1,347 +0,0 @@ -# Running the Experiment -In this demo, we are going to investigate the effect of the location of a datacenter on its carbon footprint. -The same data center is placed in four different locations; Belgium, Germany, Netherlands, and France. -Because they are getting their energy from different sources, their carbon emission is different. This demo is based on the work done for the workshop paper `FootPrinter: Quantifying Data Center Carbon Footprint` which can found [here](https://atlarge-research.com/pdfs/2024-hotcloud-footprinter.pdf). - -:::info -In this tutorial, we will learn how to create and execute a simple experiment in OpenDC. - -This is tutorial is based on a "Carbon emission demo" which can be found on the OpenDC [github](https://github.com/atlarge-research/opendc). - -Download the demo [here] to run in an interactive notebook. -::: - - -Running this demo requires OpenDC. Download the latest release [here](https://github.com/atlarge-research/opendc/releases) and put it in this folder. - -## 1. Carbon Footprint - -With the rise of high-computational workloads, such as Artificial Intelligence, datacenters are using a lot of energy. Because of this datacenters have become increasingly large contributors to the global footprint. A common definition of the carbon footprint of a datacenter is its total carbon emissions. In this demo, we are looking at the operational carbon emission of a datacenter. Operational carbon is all carbon that is emitted when producing the energy required to run a workload. - -### Carbon Intensity -Determining the operational carbon emission of a datacenter requires more than just the energy used. Equally important is the source of the energy. Using energy from non-green sources, such as coal, can emit up to 20x times as much carbon as green energy, such as solar. Carbon Intensity defines the amount of carbon emitted per unit of energy. Multiplying the energy usage of the energy by the carbon intensity of the energy source determines the carbon emission. - -Green energy is primarily gained from natural phenomena, such as wind or sunlight. This results in a continuously changing mix of available energy. This means the carbon intensity, even at the same location, is rarely stable over a long period of time. Below, we see the carbon intensity of the Dutch energy grid during a period of a week: - -![Carbon Intenisity](figures/energy_combined.png) -Figure taken from [FootPrinter](https://atlarge-research.com/pdfs/2024-hotcloud-footprinter.pdf") - -Due to the fluctuating availability of green energy (the Netherlands relies on solar and wind) there is a large variation in carbon intensity over time. This shows that to reduce the carbon emissions it is not just important to reduce energy usage, but also when and where tasks are executed. - -## 2. Carbon Traces - -OpenDC can determine the carbon emission of a datacenter when provided a carbon trace. -Carbon traces define the carbon intensity of the energy in a specific region during a particular time period. -Historical carbon traces can be collected from services such as [entsoe-e](https://www.entsoe.eu/) or [electricitymaps](https://www.electricitymaps.com/). - -Carbon traces are saved as parquet files. Lets see how they look. - -
- -###### Input -```python -import pandas as pd - -df_carbon = pd.read_parquet("carbon_traces/BE_2021-2024.parquet") -``` ---- -###### Output -
- -
- -###### Input -```python -df_carbon.head() -``` ---- -###### Output -| | timestamp | carbon_intensity | -|---:|:--------------------|-------------------:| -| 0 | 2021-01-01 00:00:00 | 254.44 | -| 1 | 2021-01-01 01:00:00 | 247.21 | -| 2 | 2021-01-01 02:00:00 | 219.78 | -| 3 | 2021-01-01 03:00:00 | 191.87 | -| 4 | 2021-01-01 04:00:00 | 211.01 | - -
- -A carbon trace contains a large number of samples containing a timestamp and carbon intensity. During simulation, OpenDC matches the simulation time with the closest sample time. - -**Note**: OpenDC will always try to match to a sample. If the simulation exceeds the range of the carbon trace, it will select the first or last sample. - -## 3. Defining Topologies - -Carbon traces are activated by the user in the `topology` file in the `powerSource` variable. -The following is a topology that is connected to the Belgium energy grid: - -```json -{ - "clusters": [ - { - "name": "C01", - "hosts": [ - { - "name": "H01", - "cpu": { - "coreCount": 16, - "coreSpeed": 2100 - }, - "memory": { - "memorySize": 100000 - }, - "powerModel": { - "modelType": "sqrt", - "power": 400.0, - "idlePower": 32.0, - "maxPower": 180.0 - }, - "count": 277 - } - ], - "powerSource": { - "carbonTracePath": "carbon_traces/BE_2021-2024.parquet" - } - } - ] -} -``` - -First we see that the datacenter has 277 nodes containing a 16 core CPU. -Next, we see that the `carbonTracePath` of the `powerSource` is pointed to the Belgium carbon trace. - -Similarly, we can create topologies for the three other contries. See the topologies of [Germany](topologies/surfsara_DE.json), [France](topologies/surfsara_FR.json), and [Netherlands](topologies/surfsara_NL.json) - -## 4. Executing the experiment - -In this tutorial we want to investigate the impact of the location of a data center on its carbon footprint. -We have defined four `topologies` that are connected to different `carbon traces`. We want to run the same workload using the different `topologies`. This can be done using the following `experiment` file: - -```json -{ - "name": "carbon", - "topologies": [ - { - "pathToFile": "topologies/surfsara_BE.json" - }, - { - "pathToFile": "topologies/surfsara_DE.json" - }, - { - "pathToFile": "topologies/surfsara_FR.json" - }, - { - "pathToFile": "topologies/surfsara_NL.json" - } - ], - "workloads": [ - { - "pathToFile": "workload_traces/surf_month", - "type": "ComputeWorkload" - } - ], - "exportModels": [ - { - "exportInterval": 3600, - "printFrequency": 168, - "filesToExport": [ - "host", - "powerSource", - "service", - "task" - ] - } - ] -} -``` - -Running this `experiment`, will run four simulations, one for each topologies. All four simulations will run a month long workload trace based on the surf LISA computer. - - -The experiment can be executed directly from the terminal. While running the experiment, OpenDC periodically prints information about the status of the simulation. In this experiment, OpenDC prints every week, but this can be changes using the `exportModel`. - -
- -###### Input -```python -import subprocess - -pathToScenario = "experiments/carbon_experiment.json" -subprocess.run(["OpenDCExperimentRunner/bin/OpenDCExperimentRunner", "--experiment-path", pathToScenario]) -``` ---- -###### Output -
- - - -================================================================================ - Running scenario: 0  -================================================================================ - Starting seed: 0  - -Simulating... 0% [ ] 0/4 (0:00:00 / ?) -12:18:37.303 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 168 hours: - Tasks Total: 21607 - Tasks Active: 124 - Tasks Pending: 0 - Tasks Completed: 21483 - Tasks Terminated: 0 - -12:18:38.659 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 336 hours: - Tasks Total: 30276 - Tasks Active: 114 - Tasks Pending: 0 - Tasks Completed: 30162 - Tasks Terminated: 0 - -12:18:40.212 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 504 hours: - Tasks Total: 58339 - Tasks Active: 145 - Tasks Pending: 0 - Tasks Completed: 58194 - Tasks Terminated: 0 - -12:18:41.502 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 672 hours: - Tasks Total: 66103 - Tasks Active: 228 - Tasks Pending: 0 - Tasks Completed: 65875 - Tasks Terminated: 0 - - - -================================================================================ - Running scenario: 1  -================================================================================ - Starting seed: 0  - -Simulating... 25% [======== ] 1/4 (0:00:10 / 0:00:30) -12:18:45.457 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 168 hours: - Tasks Total: 21607 - Tasks Active: 124 - Tasks Pending: 0 - Tasks Completed: 21483 - Tasks Terminated: 0 - -12:18:46.375 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 336 hours: - Tasks Total: 30276 - Tasks Active: 114 - Tasks Pending: 0 - Tasks Completed: 30162 - Tasks Terminated: 0 - -12:18:47.487 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 504 hours: - Tasks Total: 58339 - Tasks Active: 145 - Tasks Pending: 0 - Tasks Completed: 58194 - Tasks Terminated: 0 - -12:18:48.382 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 672 hours: - Tasks Total: 66103 - Tasks Active: 228 - Tasks Pending: 0 - Tasks Completed: 65875 - Tasks Terminated: 0 - - - -================================================================================ - Running scenario: 2  -================================================================================ - Starting seed: 0  - -Simulating... 50% [================ ] 2/4 (0:00:17 / 0:00:17) -12:18:51.612 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 168 hours: - Tasks Total: 21607 - Tasks Active: 124 - Tasks Pending: 0 - Tasks Completed: 21483 - Tasks Terminated: 0 - -12:18:52.377 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 336 hours: - Tasks Total: 30276 - Tasks Active: 114 - Tasks Pending: 0 - Tasks Completed: 30162 - Tasks Terminated: 0 - -12:18:53.505 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 504 hours: - Tasks Total: 58339 - Tasks Active: 145 - Tasks Pending: 0 - Tasks Completed: 58194 - Tasks Terminated: 0 - -12:18:54.466 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 672 hours: - Tasks Total: 66103 - Tasks Active: 228 - Tasks Pending: 0 - Tasks Completed: 65875 - Tasks Terminated: 0 - - - -================================================================================ - Running scenario: 3  -================================================================================ - Starting seed: 0  - -Simulating... 75% [======================== ] 3/4 (0:00:23 / 0:00:07) -12:18:57.573 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 168 hours: - Tasks Total: 21607 - Tasks Active: 124 - Tasks Pending: 0 - Tasks Completed: 21483 - Tasks Terminated: 0 - -12:18:58.432 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 336 hours: - Tasks Total: 30276 - Tasks Active: 114 - Tasks Pending: 0 - Tasks Completed: 30162 - Tasks Terminated: 0 - -12:18:59.582 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 504 hours: - Tasks Total: 58339 - Tasks Active: 145 - Tasks Pending: 0 - Tasks Completed: 58194 - Tasks Terminated: 0 - -12:19:00.543 [WARN ] org.opendc.compute.simulator.telemetry.ComputeMetricReader - - Metrics after 672 hours: - Tasks Total: 66103 - Tasks Active: 228 - Tasks Pending: 0 - Tasks Completed: 65875 - Tasks Terminated: 0 - - -Simulating... 100% [=================================] 4/4 (0:00:28 / 0:00:00) - -
- -``` -CompletedProcess(args=['OpenDCExperimentRunner/bin/OpenDCExperimentRunner', '--experiment-path', 'experiments/carbon_experiment.json'], returncode=0) -``` -
- -Running the simulation has created the `output` folder containing information about the experiment. -In the next part we will use these files for analysis and vizualization. diff --git a/site/docs/tutorials/2. Carbon Emission/2.2 Analysis.md b/site/docs/tutorials/2. Carbon Emission/2.2 Analysis.md deleted file mode 100644 index 3a0e86e..0000000 --- a/site/docs/tutorials/2. Carbon Emission/2.2 Analysis.md +++ /dev/null @@ -1,210 +0,0 @@ -# Analysis and Vizualization - -The next step is to analyse the results from the different carbon regions. -After running the experiment, the output folder should look like this. - -``` -[1] output ๐Ÿ“ -[2] โ”œโ”€โ”€ simple ๐Ÿ“ -[3] โ”‚ โ”œโ”€โ”€ raw-output ๐Ÿ“ -[4] โ”‚ โ”‚ |โ”€โ”€ 0 ๐Ÿ“ -[5] โ”‚ โ”‚ | โ””โ”€โ”€ seed=0 ๐Ÿ“ -[6] | | | โ””โ”€โ”€ host.parquet ๐Ÿ“„ -[7] | | | โ””โ”€โ”€ powerSource.parquet ๐Ÿ“„ -[8] | | | โ””โ”€โ”€ service.parquet ๐Ÿ“„ -[9] | | | โ””โ”€โ”€ task.parquet ๐Ÿ“„ -[10] โ”‚ โ”‚ |โ”€โ”€ 1 ๐Ÿ“ -[11] โ”‚ โ”‚ | โ””โ”€โ”€ seed=0 ๐Ÿ“ -[12] | | | โ””โ”€โ”€ host.parquet ๐Ÿ“„ -[13] | | | โ””โ”€โ”€ powerSource.parquet ๐Ÿ“„ -[14] | | | โ””โ”€โ”€ service.parquet ๐Ÿ“„ -[15] | | | โ””โ”€โ”€ task.parquet ๐Ÿ“„ -[16] โ”‚ โ”‚ |โ”€โ”€ 2 ๐Ÿ“ -[17] โ”‚ โ”‚ | โ””โ”€โ”€ seed=0 ๐Ÿ“ -[18] | | | โ””โ”€โ”€ host.parquet ๐Ÿ“„ -[19] | | | โ””โ”€โ”€ powerSource.parquet ๐Ÿ“„ -[20] | | | โ””โ”€โ”€ service.parquet ๐Ÿ“„ -[21] | | | โ””โ”€โ”€ task.parquet ๐Ÿ“„ -[22] โ”‚ โ”‚ |โ”€โ”€ 3 ๐Ÿ“ -[23] โ”‚ โ”‚ | โ””โ”€โ”€ seed=0 ๐Ÿ“ -[24] | | | โ””โ”€โ”€ host.parquet ๐Ÿ“„ -[25] | | | โ””โ”€โ”€ powerSource.parquet ๐Ÿ“„ -[26] | | | โ””โ”€โ”€ service.parquet ๐Ÿ“„ -[27] | | | โ””โ”€โ”€ task.parquet ๐Ÿ“„ -[28] | โ”œโ”€โ”€ simulation-analysis ๐Ÿ“ -[29] | โ””โ”€โ”€ trackr.json ๐Ÿ“„ -``` - -OpenDC has created four output folders in the `raw-output` folder; one for each topology. -Simulations are run in the order that they were defined in the `experiment` file. For this experiment, it means that 0->Belgium, 1->Germany, 2->France, and 3->Netherlands. If you are unsure, you can always use the `trackr.json` file to determine the setup of each simulation. - -To analyse the results, we load the results using Pandas: - -
- -###### Input -```python -import pandas as pd -import numpy as np -import matplotlib.pyplot as plt - -df_host_BE = pd.read_parquet("output/carbon/raw-output/0/seed=0/host.parquet") -df_powerSource_BE = pd.read_parquet("output/carbon/raw-output/0/seed=0/powerSource.parquet") -df_task_BE = pd.read_parquet("output/carbon/raw-output/0/seed=0/task.parquet") -df_service_BE = pd.read_parquet("output/carbon/raw-output/0/seed=0/service.parquet") - -df_host_DE = pd.read_parquet("output/carbon/raw-output/1/seed=0/host.parquet") -df_powerSource_DE = pd.read_parquet("output/carbon/raw-output/1/seed=0/powerSource.parquet") -df_task_DE = pd.read_parquet("output/carbon/raw-output/1/seed=0/task.parquet") -df_service_DE = pd.read_parquet("output/carbon/raw-output/1/seed=0/service.parquet") - -df_host_FR = pd.read_parquet("output/carbon/raw-output/2/seed=0/host.parquet") -df_powerSource_FR = pd.read_parquet("output/carbon/raw-output/2/seed=0/powerSource.parquet") -df_task_FR = pd.read_parquet("output/carbon/raw-output/2/seed=0/task.parquet") -df_service_FR = pd.read_parquet("output/carbon/raw-output/2/seed=0/service.parquet") - -df_host_NL = pd.read_parquet("output/carbon/raw-output/3/seed=0/host.parquet") -df_powerSource_NL = pd.read_parquet("output/carbon/raw-output/3/seed=0/powerSource.parquet") -df_task_NL = pd.read_parquet("output/carbon/raw-output/3/seed=0/task.parquet") -df_service_NL = pd.read_parquet("output/carbon/raw-output/3/seed=0/service.parquet") - -``` ---- -###### Output -
- -## 5. Plotting results - -To understand the effect of the location, we plot several metrics during the workload for each of the topologies. - -### Carbon Intensity - -Lets first look at the difference between the energy mix used by the different topologies. We can do this by plotting the carbon intensity of each of the datacenters. - -
- -###### Input -```python -plt.plot(df_powerSource_BE["timestamp"], df_powerSource_BE["carbon_intensity"], label="BE") -plt.plot(df_powerSource_DE["timestamp"], df_powerSource_DE["carbon_intensity"], label="DE", linestyle=':') -plt.plot(df_powerSource_FR["timestamp"], df_powerSource_FR["carbon_intensity"], label="FR", linestyle='--') -plt.plot(df_powerSource_NL["timestamp"], df_powerSource_NL["carbon_intensity"], label="NL", linestyle='-.') - -plt.legend() -plt.xlabel("Time") -plt.ylim([0, None]) -plt.ylabel("Carbon Intensity [gCO2/kWh]") -plt.title("Carbon Intensity of Datacenters in Different Regions") - -plt.show() -``` ---- -###### Output -![Figure](figures/2.2_Analysis/figure_19.png) - -
- -We see a significant difference in carbon intensity between the different locations. -The carbon intensity of Germany is up to 10x higher than that of France. - -### Power Draw - -Next, we look at the power draw of the different datacenters: - -
- -###### Input -```python -plt.plot(df_powerSource_BE["timestamp"], df_powerSource_BE["power_draw"] / 1000, label="BE") -plt.plot(df_powerSource_DE["timestamp"], df_powerSource_DE["power_draw"] / 1000, label="DE", linestyle=':') -plt.plot(df_powerSource_FR["timestamp"], df_powerSource_FR["power_draw"] / 1000, label="FR", linestyle='--') -plt.plot(df_powerSource_NL["timestamp"], df_powerSource_NL["power_draw"] / 1000, label="NL", linestyle='-.') - -plt.legend() -plt.xlabel("Time") -plt.ylim([0, None]) -plt.ylabel("Power Draw [kW]") -plt.title("Power Draw of Datacenters in Different Regions") - -plt.show() -``` ---- -###### Output -![Figure](figures/2.2_Analysis/figure_31.png) - -
- -There is no difference in power draw between the different data centers. -This is expected since they are running the same workload, using the same hardware. - -### Carbon Emission - -Finally, we look at the difference in carbon emission between the different datacenters: - -
- -###### Input -```python -plt.plot(df_powerSource_BE["timestamp"], df_powerSource_BE["carbon_emission"] / 1000, label="BE") -plt.plot(df_powerSource_DE["timestamp"], df_powerSource_DE["carbon_emission"] / 1000, label="DE", linestyle=':') -plt.plot(df_powerSource_FR["timestamp"], df_powerSource_FR["carbon_emission"] / 1000, label="FR", linestyle='--') -plt.plot(df_powerSource_NL["timestamp"], df_powerSource_NL["carbon_emission"] / 1000, label="NL", linestyle='-.') - -plt.legend() -plt.xlabel("Time") -plt.ylim([0, None]) -plt.ylabel("Carbon Emission [kgCO2]") -plt.title("Carbon Emission of Datacenters in Different Regions") - -plt.show() -``` ---- -###### Output -![Figure](figures/2.2_Analysis/figure_43.png) - -
- -As expected, the carbon emissions of the datacenters located in low-carbon regions is much lower those located in high-carbon regions. - -## 6. Aggregating results - -Finally, we would like to understand the exact differences between the different datacenters over the whole workload. We can do this by aggregating some of the output metrics: - - -
- -###### Input -```python -carbon_BE = df_powerSource_BE["carbon_emission"].sum() / 1000 -carbon_DE = df_powerSource_DE["carbon_emission"].sum() / 1000 -carbon_FR = df_powerSource_FR["carbon_emission"].sum() / 1000 -carbon_NL = df_powerSource_NL["carbon_emission"].sum() / 1000 - -print(f"Total Carbon Emission Belgium: {carbon_BE:.2f} kgCO2") -print(f"Total Carbon Emission Germany: {carbon_DE:.2f} kgCO2") -print(f"Total Carbon Emission France: {carbon_FR:.2f} kgCO2") -print(f"Total Carbon Emission Netherlands: {carbon_NL:.2f} kgCO2\n") - -diff = carbon_DE - carbon_FR - -print(f"Difference between Germany and France: {diff:.2f} kgCO2") - -print(f"Running the workload in Germany instead of France increases carbon {diff/carbon_FR:.2f}x.\n") - -``` ---- -###### Output -
- -Total Carbon Emission Belgium: 2866.14 kgCO2 -Total Carbon Emission Germany: 6558.96 kgCO2 -Total Carbon Emission France: 1328.81 kgCO2 -Total Carbon Emission Netherlands: 5007.38 kgCO2 - -Difference between Germany and France: 5230.15 kgCO2 -Running the workload in Germany instead of France increases carbon 3.94x. - - -
- -
diff --git a/site/docs/tutorials/2. Carbon Emission/experiments/carbon_experiment.json b/site/docs/tutorials/2. Carbon Emission/experiments/carbon_experiment.json deleted file mode 100644 index f796ac0..0000000 --- a/site/docs/tutorials/2. Carbon Emission/experiments/carbon_experiment.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "carbon", - "topologies": [ - { - "pathToFile": "topologies/surfsara_BE.json" - }, - { - "pathToFile": "topologies/surfsara_DE.json" - }, - { - "pathToFile": "topologies/surfsara_FR.json" - }, - { - "pathToFile": "topologies/surfsara_NL.json" - } - ], - "workloads": [ - { - "pathToFile": "workload_traces/surf_month", - "type": "ComputeWorkload" - } - ], - "exportModels": [ - { - "exportInterval": 3600, - "printFrequency": 168, - "filesToExport": [ - "host", - "powerSource", - "service", - "task" - ] - } - ] -} \ No newline at end of file diff --git a/site/docs/tutorials/2. Carbon Emission/figures/2.2_Analysis/figure_19.png b/site/docs/tutorials/2. Carbon Emission/figures/2.2_Analysis/figure_19.png deleted file mode 100644 index 356e970..0000000 Binary files a/site/docs/tutorials/2. Carbon Emission/figures/2.2_Analysis/figure_19.png and /dev/null differ diff --git a/site/docs/tutorials/2. Carbon Emission/figures/2.2_Analysis/figure_31.png b/site/docs/tutorials/2. Carbon Emission/figures/2.2_Analysis/figure_31.png deleted file mode 100644 index 58aeb37..0000000 Binary files a/site/docs/tutorials/2. Carbon Emission/figures/2.2_Analysis/figure_31.png and /dev/null differ diff --git a/site/docs/tutorials/2. Carbon Emission/figures/2.2_Analysis/figure_43.png b/site/docs/tutorials/2. Carbon Emission/figures/2.2_Analysis/figure_43.png deleted file mode 100644 index a800b24..0000000 Binary files a/site/docs/tutorials/2. Carbon Emission/figures/2.2_Analysis/figure_43.png and /dev/null differ diff --git a/site/docs/tutorials/2. Carbon Emission/figures/energy_combined.png b/site/docs/tutorials/2. Carbon Emission/figures/energy_combined.png deleted file mode 100644 index 649d568..0000000 Binary files a/site/docs/tutorials/2. Carbon Emission/figures/energy_combined.png and /dev/null differ diff --git a/site/docs/tutorials/2. Carbon Emission/topologies/surfsara_BE.json b/site/docs/tutorials/2. Carbon Emission/topologies/surfsara_BE.json deleted file mode 100644 index 857e298..0000000 --- a/site/docs/tutorials/2. Carbon Emission/topologies/surfsara_BE.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "clusters": [ - { - "name": "C01", - "hosts": [ - { - "name": "H01", - "cpu": { - "coreCount": 16, - "coreSpeed": 2100 - }, - "memory": { - "memorySize": 100000 - }, - "powerModel": { - "modelType": "sqrt", - "power": 400.0, - "idlePower": 32.0, - "maxPower": 180.0 - }, - "count": 277 - } - ], - "powerSource": { - "carbonTracePath": "carbon_traces/BE_2021-2024.parquet" - } - } - ] -} \ No newline at end of file diff --git a/site/docs/tutorials/2. Carbon Emission/topologies/surfsara_DE.json b/site/docs/tutorials/2. Carbon Emission/topologies/surfsara_DE.json deleted file mode 100644 index 51e8d35..0000000 --- a/site/docs/tutorials/2. Carbon Emission/topologies/surfsara_DE.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "clusters": [ - { - "name": "C01", - "hosts": [ - { - "name": "H01", - "cpu": { - "coreCount": 16, - "coreSpeed": 2100 - }, - "memory": { - "memorySize": 100000 - }, - "powerModel": { - "modelType": "sqrt", - "power": 400.0, - "idlePower": 32.0, - "maxPower": 180.0 - }, - "count": 277 - } - ], - "powerSource": { - "carbonTracePath": "carbon_traces/DE_2021-2024.parquet" - } - } - ] -} \ No newline at end of file diff --git a/site/docs/tutorials/2. Carbon Emission/topologies/surfsara_FR.json b/site/docs/tutorials/2. Carbon Emission/topologies/surfsara_FR.json deleted file mode 100644 index b274aef..0000000 --- a/site/docs/tutorials/2. Carbon Emission/topologies/surfsara_FR.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "clusters": [ - { - "name": "C01", - "hosts": [ - { - "name": "H01", - "cpu": { - "coreCount": 16, - "coreSpeed": 2100 - }, - "memory": { - "memorySize": 100000 - }, - "powerModel": { - "modelType": "sqrt", - "power": 400.0, - "idlePower": 32.0, - "maxPower": 180.0 - }, - "count": 277 - } - ], - "powerSource": { - "carbonTracePath": "carbon_traces/FR_2021-2024.parquet" - } - } - ] -} \ No newline at end of file diff --git a/site/docs/tutorials/2. Carbon Emission/topologies/surfsara_NL.json b/site/docs/tutorials/2. Carbon Emission/topologies/surfsara_NL.json deleted file mode 100644 index 001479a..0000000 --- a/site/docs/tutorials/2. Carbon Emission/topologies/surfsara_NL.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "clusters": [ - { - "name": "C01", - "hosts": [ - { - "name": "H01", - "cpu": { - "coreCount": 16, - "coreSpeed": 2100 - }, - "memory": { - "memorySize": 100000 - }, - "powerModel": { - "modelType": "sqrt", - "power": 400.0, - "idlePower": 32.0, - "maxPower": 180.0 - }, - "count": 277 - } - ], - "powerSource": { - "carbonTracePath": "carbon_traces/NL_2021-2024.parquet" - } - } - ] -} \ No newline at end of file diff --git a/site/docs/tutorials/M3SA-integration-tutorial.mdx b/site/docs/tutorials/M3SA-integration-tutorial.mdx deleted file mode 100644 index c09011c..0000000 --- a/site/docs/tutorials/M3SA-integration-tutorial.mdx +++ /dev/null @@ -1,188 +0,0 @@ ---- -sidebar_position: 2 -title: M3SA Integration -hide_title: true -sidebar_label: M3SA Integration -description: M3SA Integration ---- - -# M3SA integration tutorial - -M3SA is a tool able to perform "Multi-Meta-Model Simulation Analysis". The tool is designed to analyze the output of -simulations, by leveraging predictions, generate Multi-Model graphs, novel models, and more. M3SA can integrate with any -simulation infrastructure, as long as integration steps are followed. - -We build our tool towards performance, scalability, and **universality**. In this document, we present the steps to -integrate our tool into your simulation infrastructure. - -If you are using OpenDC, none of adaptation steps are necessary, yet they can be useful to understand the structure -of the tool. Step 3 is still necessary. - -## Step 1: Adapt the simulator output folder structure - -The first step is to adapt the I/O of your simulation to the format of our tool. The output folder structure should have -the -following format: - -``` -[1] โ”€โ”€ {simulation-folder-name} ๐Ÿ“ ๐Ÿ”ง -[2] โ”œโ”€โ”€ inputs ๐Ÿ“ ๐Ÿ”’ -[3] โ”‚ โ””โ”€โ”€ {m3sa-config-file}.json ๐Ÿ“„ ๐Ÿ”ง -[4] โ”‚ โ””โ”€โ”€ {other input files / folders} ๐Ÿ”ง -[5] โ”œโ”€โ”€ outputs ๐Ÿ“ ๐Ÿ”’ -[6] โ”‚ โ”œโ”€โ”€ raw-output ๐Ÿ“ ๐Ÿ”’ -[7] โ”‚ โ”‚ โ”œโ”€โ”€ 0 ๐Ÿ“ ๐Ÿ”’ -[8] โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ seed={your_seed}๐Ÿ”’ -[9] โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ {simulation_data_file}.parquet ๐Ÿ“„ ๐Ÿ”ง -[10] โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ {any other files / folders} โšช -[11] โ”‚ โ”‚ โ”œโ”€โ”€ 1 ๐Ÿ“ โšช ๐Ÿ”’ -[12] โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ seed={your_seed} ๐Ÿ“ โšช ๐Ÿ”’ -[13] โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ {simulation_data_file}.parquet ๐Ÿ“„ โšช ๐Ÿ”ง -[14] โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ {any other files / folders} โšช๓ ช -[15] โ”‚ โ”‚ โ”œโ”€โ”€ metamodel ๐Ÿ“ โšช -[16] โ”‚ โ”‚ โ””โ”€โ”€ seed={your_seed} ๐Ÿ“ โšช -[17] โ”‚ โ”‚ โ””โ”€โ”€ {your_metric_name}.parquet ๐Ÿ“„ โšช -[18] โ”‚ โ”‚ โ””โ”€โ”€ {any other files / folders} โšช -[19] โ”‚ โ””โ”€โ”€ {any other files / folders} ๐Ÿ“ โšช -[20]| โ””โ”€โ”€{any other files / folders} ๐Ÿ“ โšช -``` - -๐Ÿ“„ = file
-๐Ÿ“ = folder
-๐Ÿ”’ = fixed, the name of the folder/file must be the same.
-๐Ÿ”ง = flexible, the name of the folder/file can differ. However, the item must be present.
-โšช = optional and flexible. The item can be absent.
- -- [1] = the name of the analyzed folder. -- [2] = the _inputs_ folder, containing various inputs / configuration files. -- [3] = the configuration file for M3SA, flexible naming, but needs to be a JSON file -- [4],[10],[14],[18],[19],[20] = any other input files or folders. -- [5] = the _outputs_ folder, containing the raw-output. can contain any other files or folders, besides the raw-output -folder. -After running a simulation, also a "simulation-analysis" folder will be generated in this folder. -- [6] = raw-output folder, containing the raw output of the simulation. -- [7],[11] = the IDs of the models. Must always start from zero. Possible values are 0, 1, 2 ... n, and "metamodel". The -id -of "metamodel" is reserved for the Meta-Model. Any simulation data in the respective folder will be treated as -Meta-Model data. -- [8],[12] = the seed of the simulation. the seed must be the same for both [8], [12], and other equivalent, further -files. -- [9],[13] = the file in which the simulation data is stored. The name of the file can differ, but it must be a parquet -file. -- [15] = the Meta-Model folder, optional. If the folder is present, its data will be treated as Meta-Model data. -- [16] = the Meta-Model seed folder. The seed must be the same as the seed of the simulation. -- [17] = the Meta-Model output. The name of the file is of the type ```{your_metric_name}.parquet```. For example, if -you analyze CO2 emissions, the file will be named ```co2_emissions.parquet```. - ---- - -## Step 2: Adapt the simulation file format - -The simulator data file must be a ๐Ÿชต _parquet_ ๐Ÿชต file. - -The file must contain (at least) the columns: - -- timestamp: the timestamp, in miliseconds, of the data point (e.g., 30000, 60000, 90000) - the time unit is flexible. -- {metric_name}: the value of the metric at the given timestamp. This is the metric analyzed (e.g., CO2_emissions, -energy_usage). - -e.g., if you are analyzing the CO2 emissions of a datacenter, for a timeperiod of 5 minutes, and the data is sampled -every 30 seconds, the file will look like this: - -| timestamp | co2_emissions | -|-----------|---------------| -| 30000 | 31.2 | -| 60000 | 31.4 | -| 90000 | 28.5 | -| 120000 | 31.8 | -| 150000 | 51.5 | -| 180000 | 51.2 | -| 210000 | 51.4 | -| 240000 | 21.5 | -| 270000 | 21.8 | -| 300000 | 21.2 | - ---- - -## Step 3: Running M3SA - -### 3.1 Setup the Simulator Specifics - -Update the simulation folder name ([9], [13], [17] from Step 1), in the -file ```simulator_specifics.py```, from ```opendc/src/python/simulator_specifics.py```. - -### 3.2 Setup the python program arguments - -### Arguments for Main.py Setup -Main.py takes two arguments: - -1. Argument 1 is the path to the output directory where M3SA output files will be stored. -2. Argument 2 is the path to the input file that contains the configuration of M3SA. - -e.g., - -```json -"simulation-123/outputs/" "simulation-123/inputs/m3sa-configurator.json" -``` - -### 3.3 Working directory Main.py Setup - -Make sure to set the working directory to the directory where the main.py file is located. - -e.g., - -``` -/your/path/to-analyzer/src/main/python -``` - -If you are using OpenDC, you can set the working directory to the following path: - -``` -/your/path/opendc/opendc-analyze/src/main/python -``` - ---- - -## Optional: Step 4: Simulate and analyze, with one click - -The simulation and analysis can be executed as a single command; if no errors are encountered, from the user -perspective, -this operation is atomic. We integrated M3SA into OpenDC to facilitate this process. - -To further integrate M3SA into any simulation infrastructure, M3SA needs to called from -the simulation infrastructure, and provided the following running setup: - -1. script language: Python -2. argument 1: the path of the output directory, in which M3SA output files will be stored -3. argument 2: the path of the input file, containing the configuration of M3SA -4. other language-specific setup - -For example, the integration of the M3SA into OpenDC can be found -in ```Analyzr.kt``` from ```opendc-analyze/src/main/kotlin/Analyzr.kt```. -Below, we provide a snippet of the code: - -```kotlin -val ANALYSIS_SCRIPTS_DIRECTORY: String = "./opendc-analyze/src/main/python" -val ABSOLUTE_SCRIPT_PATH: String = - Path("$ANALYSIS_SCRIPTS_DIRECTORY/main.py").toAbsolutePath().normalize().toString() -val SCRIPT_LANGUAGE: String = "python3" - -fun analyzeResults(outputFolderPath: String, analyzerSetupPath: String) { - val process = ProcessBuilder( - SCRIPT_LANGUAGE, - ABSOLUTE_SCRIPT_PATH, - outputFolderPath, // argument 1 - analyzerSetupPath // argument 2 - ) - .directory(Path(ANALYSIS_SCRIPTS_DIRECTORY).toFile()) - .start() - - val exitCode = process.waitFor() - if (exitCode == 0) { - println("[Analyzr.kt says] Analysis completed successfully.") - } else { - val errors = process.errorStream.bufferedReader().readText() - println("[Analyzr.kt says] Exit code ${exitCode}; Error(s): $errors") - } -} -``` diff --git a/site/docs/tutorials/_category_.json b/site/docs/tutorials/_category_.json deleted file mode 100644 index 5d3c1ca..0000000 --- a/site/docs/tutorials/_category_.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "label": "Tutorials", - "position": 3, - "link": { - "type": "generated-index", - "description": "Tutorials demonstrating how to conduct experiments with OpenDC." - - } -} diff --git a/site/docusaurus.config.js b/site/docusaurus.config.js index eae15fe..e5bba4e 100644 --- a/site/docusaurus.config.js +++ b/site/docusaurus.config.js @@ -1,145 +1,114 @@ // @ts-check -const organizationName = "atlarge-research"; -const projectName = "opendt"; +const organizationName = 'atlarge-research' +const projectName = 'opendt' -const lightCodeTheme = require("prism-react-renderer/themes/github"); -const darkCodeTheme = require("prism-react-renderer/themes/dracula"); +const lightCodeTheme = require('prism-react-renderer/themes/github') +const darkCodeTheme = require('prism-react-renderer/themes/dracula') /** @type {import("@docusaurus/types").Config} */ const config = { - title: "OpenDT", - tagline: "Digital Twin for Datacenters", + title: 'OpenDT', + tagline: 'Digital Twin for Datacenters', url: process.env.DOCUSAURUS_URL || `https://${organizationName}.github.io`, baseUrl: process.env.DOCUSAURUS_BASE_PATH || `/${projectName}/`, - onBrokenLinks: "throw", - onBrokenMarkdownLinks: "warn", - favicon: "img/favicon.ico", + onBrokenLinks: 'throw', + onBrokenMarkdownLinks: 'warn', + favicon: 'img/favicon.ico', organizationName, projectName, i18n: { - defaultLocale: "en", - locales: ["en"] + defaultLocale: 'en', + locales: ['en'], }, presets: [ [ - "classic", + 'classic', /** @type {import("@docusaurus/preset-classic").Options} */ ({ docs: { - sidebarPath: require.resolve("./sidebars.js"), - editUrl: `https://github.com/${organizationName}/${projectName}/tree/master/site/` + sidebarPath: require.resolve('./sidebars.js'), + editUrl: `https://github.com/${organizationName}/${projectName}/tree/master/site/`, }, theme: { - customCss: require.resolve("./src/css/custom.css") - } - }) - ] - ], - - plugins: [ - [ - "content-docs", - // /** @type {import("@docusaurus/plugin-content-docs").Options} */ - ({ - id: "community", - path: "community", - routeBasePath: "community", - editUrl: `https://github.com/${organizationName}/${projectName}/tree/master/site/`, - sidebarPath: require.resolve("./sidebars.js") - }) - ] + customCss: require.resolve('./src/css/custom.css'), + }, + }), + ], ], themeConfig: - /** @type {import("@docusaurus/preset-classic").ThemeConfig} */ + /** @type {import("@docusaurus/preset-classic").ThemeConfig} */ ({ navbar: { - title: "OpenDT", + title: 'OpenDT', logo: { - alt: "OpenDT logo", - src: "/img/logo.svg" + alt: 'OpenDT logo', + src: '/img/logo.svg', }, items: [ { - type: "doc", - docId: "intro", - position: "left", - label: "Learn" + type: 'doc', + docId: 'intro', + position: 'left', + label: 'Docs', }, - { - to: "/community/support", - label: "Community", - position: "left", - activeBaseRegex: `/community/` - }, - // { - // href: "https://app.opendc.org", - // html: "Log In", - // position: "right", - // className: "header-app-link button button--outline button--primary", - // "aria-label": "OpenDC web application", - // }, { href: `https://github.com/${organizationName}/${projectName}`, - position: "right", - className: "header-github-link", - "aria-label": "GitHub repository", + position: 'right', + className: 'header-github-link', + 'aria-label': 'GitHub repository', }, - ] + ], }, footer: { - style: "dark", + style: 'dark', links: [ { - title: "Learn", + title: 'Docs', items: [ { - label: "Getting Started", - to: "/docs/category/getting-started" + label: 'Getting Started', + to: '/docs/category/getting-started', }, { - label: "Tutorials", - to: "/docs/category/tutorials" - } - ] - }, - { - title: "Community", - items: [ + label: 'Concepts', + to: '/docs/category/concepts', + }, { - label: "Support", - to: "/community/support" + label: 'Configuration', + to: '/docs/category/configuration', }, { - label: "Team", - to: "/community/team" + label: 'Services', + to: '/docs/category/services', }, - // { - // label: "GitHub Discussions", - // href: `https://github.com/${organizationName}/${projectName}/discussions` - // } - ] + ], }, { - title: "More", + title: 'More', items: [ { - label: "GitHub", - href: `https://github.com/${organizationName}/${projectName}` - } - ] - } + label: 'GitHub', + href: `https://github.com/${organizationName}/${projectName}`, + }, + { + label: 'OpenDC', + href: 'https://opendc.org', + }, + ], + }, ], - copyright: `Copyright ยฉ ${new Date().getFullYear()} AtLarge Research. Built with Docusaurus.` + copyright: `Copyright ยฉ ${new Date().getFullYear()} AtLarge Research. Built with Docusaurus.`, }, prism: { theme: lightCodeTheme, - darkTheme: darkCodeTheme - } - }) -}; + darkTheme: darkCodeTheme, + additionalLanguages: ['bash', 'json', 'yaml'], + }, + }), +} -module.exports = config; +module.exports = config diff --git a/site/old_files/advanced-guides/_category_.json b/site/old_files/advanced-guides/_category_.json deleted file mode 100644 index a74f4f4..0000000 --- a/site/old_files/advanced-guides/_category_.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "label": "Advanced Guides", - "position": 4, - "link": { - "type": "generated-index" - } -} diff --git a/site/old_files/advanced-guides/architecture.md b/site/old_files/advanced-guides/architecture.md deleted file mode 100644 index 2a65a6c..0000000 --- a/site/old_files/advanced-guides/architecture.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -sidebar_position: 2 ---- - -# Architecture - -OpenDC consists of four components: a Kotlin simulator, a SQL database, a Quarkus-based -[API](https://github.com/atlarge-research/opendc/tree/master/opendc-web/opendc-web-api), and a -React.js [frontend](https://github.com/atlarge-research/opendc/tree/master/opendc-web/opendc-web-api). - -![OpenDC Component Diagram](img/component-diagram.png) - -On the frontend, users can construct a topology by specifying a datacenter's rooms, racks and machines, and create -scenarios to see how a workload trace runs on that topology. The frontend communicates with the web server via a REST -API over HTTP. - -The (Swagger/OpenAPI compliant) API spec specifies what requests the frontend can make to the web server. To view this -specification, go to the [Swagger Editor](https://editor.swagger.io/) and paste in -our [API spec](https://api.opendc.org/q/openapi). - -The web server receives API requests and processes them in the database. When the frontend requests to run a new -scenario, the web server adds it to the `scenarios` collection in the database and sets its `state` as `PENDING`. - -The simulator monitors the database for `PENDING` scenarios, and simulates them as they are submitted. The results of -the simulations are processed and aggregated in memory. Afterwards, the aggregated summary is written to the database, -which the frontend can then again retrieve via the web server. diff --git a/site/old_files/advanced-guides/deploy.md b/site/old_files/advanced-guides/deploy.md deleted file mode 100644 index 2ee69c0..0000000 --- a/site/old_files/advanced-guides/deploy.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -sidebar_position: 3 ---- - -# Deploying OpenDC -This document explains how you can deploy a multi-tenant instance of OpenDC using Docker. - -## Contents - -1. [Setting up Auth0](#setting-up-auth0) -1. [Installing Docker](#installing-docker) -1. [Running OpenDC from source](#running-opendc-from-source) - -## Setting up Auth0 - -OpenDC uses [Auth0](https://auth0.com) as Identity Provider so that OpenDC does not have to manage user data itself, -which greatly simplifies our frontend and backend implementation. We have chosen to use Auth0 as it is a well-known -Identity Provider with good software support and a free tier for users to experiment with. - -To deploy OpenDC yourself, you need to have an [Auth0 tenant](https://auth0.com/docs/get-started/learn-the-basics) and -create: - -1. **An API** - You need to define the OpenDC API server in Auth0. Please refer to the [following guide](https://auth0.com/docs/quickstart/backend/python/01-authorization#create-an-api) - on how to define an API in Auth0. - - Remember the identifier you created the API with, as we need it in the next steps (as `OPENDC_AUTH0_AUDIENCE`). -2. **A Single Page Application (SPA)** - You need to define the OpenDC frontend application in Auth0. Please see the [following guide](https://auth0.com/docs/quickstart/spa/react#configure-auth0) - on how you can define an SPA in Auth0. Make sure you have added the necessary URLs to the _Allowed Callback URLs_: - for a local deployment, you should add at least `http://localhost:3000, http://localhost:8080`. - - Once your application has been created, you should have a _Domain_ and _Client ID_ which we need to pass to the - frontend application (as `OPENDC_AUTH0_DOMAIN` and `OPENDC_AUTH0_CLIENT_ID` respectively). - - -## Installing Docker - -OpenDC uses [Docker](https://www.docker.com/) and [Docker Compose](https://docs.docker.com/compose/) to orchestrate the -deployment of the software stack. Please refer to [Docker Desktop](https://www.docker.com/products/docker-desktop) for -instructions on how install Docker on your machine. - -## Running OpenDC from source - -To build and run the full OpenDC stack locally on Linux or Mac, you first need to clone the project: - -```bash -git clone https://github.com/atlarge-research/opendc.git - -# Enter the directory -cd opendc/ -``` - -In the directory you just entered, you need to set up a set of environment variables. To do this, create a file -called `.env` in the `opendc` folder. In this file, replace `your-auth0-*` with the Auth0 details you got from the first -step. For a standard setup, you can leave the other settings as-is. - -```.env -OPENDC_DB_USERNAME=opendc -OPENDC_DB_PASSWORD=opendcpassword -OPENDC_AUTH0_DOMAIN=your-auth0-domain -OPENDC_AUTH0_CLIENT_ID=your-auth0-client-id -OPENDC_AUTH0_AUDIENCE=your-auth0-api-identifier -OPENDC_API_BASE_URL=http://web -``` - -We provide a set of default traces for you to experiment with. If you want to add others, place them in the `traces` -directory and add entries to the database (see also [the SQL init script](https://github.com/atlarge-research/opendc/tree/master/opendc-web/opendc-web-server/src/main/resources/db/migration/V1.0.0__core.sql)) - -If you plan to deploy publicly, please also tweak the other settings. In that case, also check the `docker-compose.yml` -and `docker-compose.prod.yml` for further instructions. - -Now, start the server: - -```bash -# Build the Docker image -docker-compose build - -# Start the containers -docker-compose up -``` - -Wait a few seconds and open `http://localhost:8080` in your browser to use OpenDC. We recommend Google Chrome for the -best user experience. diff --git a/site/old_files/advanced-guides/img/component-diagram.png b/site/old_files/advanced-guides/img/component-diagram.png deleted file mode 100644 index 312ca72..0000000 Binary files a/site/old_files/advanced-guides/img/component-diagram.png and /dev/null differ diff --git a/site/old_files/advanced-guides/toolchain.md b/site/old_files/advanced-guides/toolchain.md deleted file mode 100644 index 1673d97..0000000 --- a/site/old_files/advanced-guides/toolchain.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -sidebar_position: 1 ---- - -# Toolchain Setup - -The OpenDC simulator is built using the [Kotlin](https://kotlinlang.org/) language. This is a JVM-based language that -should appear familiar to programmers knowledgeable in Java or Scala. For a short interactive introduction to Kotlin, -the [Learn Kotlin By Example](https://play.kotlinlang.org/byExample/overview) docs are a great place to start. - -For the build and dependency toolchain, we use [Gradle](https://gradle.org/). You will likely not need to change the -Gradle build configurations of components, but you will use Gradle to execute builds and tests on the codebase. - -Follow the steps below to get it all set up! - -## Contents - -1. [Installing Java](#1-installing-java) -2. [Building and Developing](#2-building-and-developing) -3. [Setup with IntelliJ IDEA](#21-setup-with-intellij-idea) -4. [Setup with Command Line](#22-setup-with-command-line) - -## 1. Installing Java - -OpenDC requires a Java installation of version 17 or higher. Make sure to install -the [JDK](https://www.oracle.com/technetwork/java/javase/downloads/index.html), not only the JRE (the JDK also includes -a JRE). - -## 2. Building and Developing - -With Java installed, we're ready to set up the development environment on your machine. You can either use a visual IDE -or work from a command line shell. We outline both approaches below, feel free to choose which you are most comfortable -with. If in doubt which one to choose, we suggest going with the first one. - -## 2.1 Setup with IntelliJ IDEA - -We suggest using [IntelliJ IDEA](https://www.jetbrains.com/idea/) as development environment. Once you have installed -any version of this IDE on your machine, choose "Get from Version Control" in the new project dialogue. -Enter `https://github.com/atlarge-research/opendc` as URL and submit your credentials when asked. -Open the project once it's ready fetching the codebase, and let it set up with the defaults (IntelliJ will recognize -that this is a Gradle codebase). - -You will now be prompted in a dialogue to enable auto-import for Gradle, which we suggest you do. Wait for any progress -bars in the lower bar to disappear and then look for the Gradle simHyperVisorContext menu on the right-hand side. In it, go -to `opendc > Tasks > verification > test`. This will build the codebase and run checks to verify that tests -pass. If you get a `BUILD SUCCESSFUL` message, you're ready to go to the [next section](architecture)! - -## 2.2 Setup with Command Line - -First, clone the repository with the following command: - -```shell script -git clone https://github.com/atlarge-research/opendc -``` - -And enter the directory: - -```shell script -cd opendc -``` - -If on Windows, run the batch file included in the root, as follows: - -```commandline -gradlew.bat test -``` - -If on Linux/macOS, run the shell script included in the root, as follows: - -```shell script -./gradlew test -``` - -If the build is successful, you are ready to go to the next section! diff --git a/site/old_files/cloud-capacity-planning.mdx b/site/old_files/cloud-capacity-planning.mdx deleted file mode 100644 index df9cb56..0000000 --- a/site/old_files/cloud-capacity-planning.mdx +++ /dev/null @@ -1,277 +0,0 @@ ---- -sidebar_position: 1 -title: Cloud Capacity Planning -hide_title: true -sidebar_label: Cloud Capacity Planning -description: Cloud Capacity Planning ---- - -# Cloud Capacity Planning Tutorial - -Using OpenDC to plan and design cloud datacenters. - -:::info Learning goal - -By doing this assignment, you will learn more about the basic concepts of datacenters and cloud computing systems, as -well as how such systems are planned and designed. -::: - -## Preamble - -Datacenter infrastructure is important in todayโ€™s digital society. Stakeholders across industry, government, and -academia employ a vast and diverse array of cloud services hosted by datacenter infrastructure, and expect services to -be reliable, high speed, and low cost. In turn, datacenter operators must maintain efficient operation at unprecedented -scale. - -To keep up with growing demand and increasing complexity, architects of datacenters must address complex challenges in -distributed systems, software engineering and performance engineering. One of these challenges is efficient utilization -of resources in datacenters, which is only 6-12% industry-wide despite the fact that it is inconvenient for datacenter -operators to keep much of their infrastructure idle, due to resulting high energy consumption and thus unnecessary -costs. - -It is often quite difficult to implement optimizations or other changes in datacenters. Datacenter operators tend to be -conservative in adopting such changes in fear of failure or misbehaving systems. Furthermore, testing changes at the -scale of modern datacenter infrastructure in a real-world setting is prohibitively expensive and hard to reproduce, -notwithstanding environmental concerns. - -A more viable alternative is the use of datacenter simulators such as OpenDC or CloudSim. These tools model datacenter -infrastructure at a good accuracy and allow us to test changes in a controllable and repeatable environment. - -In this tutorial, we will use the OpenDC datacenter simulator to experiment with datacenters and demonstrate the -process of designing and optimizing datacenters using simulation. - -## What is OpenDC - -OpenDC is an open source platform for datacenter simulation developed by AtLarge Research. The purpose of OpenDC is -twofold: we aim to both enable cloud computing education and support research into datacenters. -An example of the former is this tutorial, and examples of the latter include the numerous BSc and MSc research projects -that are using OpenDC to run experiments and perform research. - -:::caution - -OpenDC is still an experimental tool. Your data may get lost, overwritten, or otherwise become unavailable. Sorry for -the inconvenience. - -::: - -If you are not familiar with the OpenDC web interface, please follow the [Getting Started](/docs/category/getting-started) -guide to get an understanding of the main concepts of OpenDC and how to design a datacenter. - -:::note Action - -Set up a project on the OpenDC website. - -::: - - -## Assignment - -Acme Inc. is a small datacenter operator in the Netherlands. They are currently in the process of acquiring a new client -and closing a deal where Acme will migrate all of the clientโ€™s business-critical workloads from internal machines to -Acmeโ€™s datacenters. With this deal, the client aims to outsource the maintenance of their digital infrastructure, but in -turn expects reliable and efficient operation from Acme. - -To demonstrate that Acme is capable of this task, it has started a pilot project with the client where Acme will migrate -already a small subset of the clientโ€™s workloads. You are an engineer at Acme. and have been tasked with the design and -procurement of the datacenter infrastructure required for this pilot project. - -To guide your design, the client has provided a workload trace of their business-critical workloads, which consist of -the historical runtime behavior of 50 virtual machines over time. These virtual machines differ in resource -requirements (e.g. number of vCPUs or memory) and in resource consumption over time. We can use OpenDC to simulate this -workload trace and validate your datacenter design. - -The assignment is divided into four parts: -1. Analyzing the requirements to estimate what resources are needed. -2. Building your design in OpenDC -3. Validating your design in OpenDC -4. Optimizing your design in OpenDC - -Make notes of your thoughts on the following assignments & questions and discuss with your partner(s). - -## Analyze the Requirements - -The first step of the assignment is to analyze the requirements of the client in order to come up with a reasonable -estimation of the datacenter infrastructure needed. This estimation will become our initial design which we will build -and validate in OpenDC. - -Since the client has provided a workload trace representative of the workload that will eventually be running in the -datacenter, we can use it to guide our design. In [Figure 1](#resource-distribution), the requested memory and vCPUs are -depicted for the virtual machines in the workload trace. - -:::note Action - -Determine the total amount of vCPUs and memory required in the trace. - -::: - -
- Resource requirements for the workload -
- Requested number of vCPUs and memory (in GB) by the - virtual machines in the workload. The left figure shows the number of virtual machines that have requested 1, 2, 4 or 8 - vCPUs. The right figure shows the amount of memory requested compared to the number of vCPUs in the virtual machine. -
-
- -Based on this information, we could choose to purchase a new machine for every virtual machine in the workload trace. -Such a design will most certainly be able to handle the workload. At the same time, it is much more expensive and -probably unnecessary. - -In [Figure 2](#cpu-usage), the CPU Usage (in MHz) of the virtual machines in the workload is depicted over time. Observe that the -median CPU usage of the virtual machines over the whole trace is approximately 100 MHz. This means that a 2-core -processor with a base clock 3500 MHz would have utilization of only 1.4% (`100 MHz / (3500 MHz x 2)`) for such a median -workload. - -
- CPU usage over time for the workload -
CPU Usage of the virtual machines in the workload over time.
-
- -Instead, we could try to fit multiple virtual machines onto a single machine. For instance, the 2-core processor -mentioned before is able to handle 70 virtual machines, each running at 100 MHz (`(3500 MHz x 2) / 100 MHz`), ignoring -virtualization overhead and memory requirements. - -:::note Action - -Make a rough estimate of the number of physical cores required to host the vCPUs in the workload trace. - -::: - -Now that we have an indication of the number of physical cores we need to have, we can start to compose the servers in -our datacenter. See **Table 1 and 2** for the equipment list you can choose from. Donโ€™t forget to put enough memory in your -servers, or otherwise you risk that not all virtual machines will fit on the servers in your datacenter. - -| Processor | Intelยฎ Xeonยฎ E-2224G | Intelยฎ Xeonยฎ E-2244G | Intelยฎ Xeonยฎ E-2246G | -|----------------------------------|----------------------|----------------------|----------------------| -| Base clock (in MHz) | 3500 | 3800 | 3600 | -| Core count | 4 | 8 | 12 | -| Average power consumption (in W) | 71 | 71 | 80 | - -**Table 1:** Processor options for your datacenter - - -| Memory module | Crucial MTA9ASF2G72PZ-3G2E | Crucial MTA18ASF4G72PDZ-3G2E1 | -|----------------------------------|----------------------------|-------------------------------| -| Size (in GB) | 16 | 32 | -| Speed (in MHZ) | 3200 | 3200 | -**Table 2:** Memory options for your datacenter - - -:::note Action - -Create a plan detailing the servers you want to have in your datacenter and what resources (e.g. processor or memory) -they should contain. For instance, such a plan could look like: - -1. 8x Server (2x Intelยฎ Xeonยฎ E-2244G, 4x Crucial MTA18ASF4G72PDZ-3G2E1) - -::: - -:::tip Hint - -Budget more capacity than your initial estimates to prevent your datacenter from running at a very high -utilization. Think about how your datacenter would handle a machine failure, will you still have enough capacity left? - -::: - -## Build the datacenter - -Based on the plan we devised in the previous section, we will now construct a (virtual) datacenter in OpenDC. If you -have not yet used the OpenDC web interface to design a datacenter, please read [Getting Started](/docs/category/getting-started) -guide to get an understanding of the main concepts of OpenDC and how to design a datacenter. - -:::note Action - -Implement your plan in the OpenDC web interface. - -::: - -## Validate your design - -We are now at a stage where we can validate whether the datacenter we have just designed and built in OpenDC is suitable -for the workload of the client. We will use OpenDC to simulate the workload in our datacenter and keep track of several -metrics to ensure efficient and reliable operation. - -One of our concerns is that our datacenter does not have enough computing power to deal with the clientโ€™s -business-critical workload, leading to degraded performance and consequently an unhappy client. - -A metric that gives us an insight in performance degradation is the Overcommitted CPU Cycles, which represents the -number of CPU cycles that a virtual machine wanted to run, but could not due to the host machine not having enough -computing capacity at that moment. To keep track of this metric during simulation, we create a new portfolio by clicking -the โ€˜+โ€™ next to โ€œPortfolioโ€ in the left sidebar and select the metrics of interest. - -:::note Action - -Add a new portfolio and select at least the following targets: -1. Overcommitted CPU Cycles -2. Granted CPU Cycles -3. Requested CPU Cycles -4. Maximum Number VMs Finished - -::: - -We will now try to simulate the clientโ€™s workload trace (called _Bitbrains (Sample)_ in OpenDC). By clicking on โ€˜New -Scenarioโ€™ below your created portfolio, we can create a base scenario which will represent our baseline datacenter -design which we will compare against future improvements. - -:::note Action - -Add a base scenario to your new portfolio and select as trace _Bitbrains (Sample)_. - -::: - -By creating a new scenario, you will schedule a simulation of your datacenter design that will run on one of the OpenDC -simulation servers. Press the Play button next to your portfolio to see the results of the simulations. If you have -chosen the _Bitbrains (Sample)_ trace, the results should usually appear within one minute or less depending on the queue -size. In case they do not appear within a reasonable timeframe, please contact the instructors. - -You can now see how your design has performed. Check whether all virtual machines have finished and whether the -_Overcommitted CPU Cycles_ metric is not too high. Try to aim for anything below 1 bn cycles. In the next section, weโ€™ll -try to further optimize our design. For now, think of an explanation for the performance of your design. - -## Optimize your design - -Finally, letโ€™s try to optimize your design so that it meets the requirements of the client and is beneficial for your -employer as well. In particular, your company is interested in the follow goals: - -1. Reducing _Overcommitted CPU Cycles_ to a minimum for reliability. -2. Reducing _Total Power Consumption_ to a minimum to save energy costs. - -:::note Action - -Add a new portfolio and select at least the following targets: -1. Overcommitted CPU Cycles -2. Granted CPU Cycles -3. Requested CPU Cycles -4. Total Power Consumption - -Then, add a base scenario to your new portfolio and select as trace _Bitbrains (Sample)_. - -::: - -Try to think of ways in which you can reduce both _Overcommitted CPU Cycles_ and _Total Power Consumption_. Create a new -topology based on your initial topology and apply the changes you have come up with. In this way, you can easily compare -the performance of different topologies in different scenarios. Note that the choice of scheduler might also influence -your results. - -:::tip Hint - -The choice of scheduler (and thus the placement of VMs) might also influence your results. - -::: - - -:::note Action - -1. Create a new topology based on your existing topology. -2. Add a new scenario to your created portfolio and select your newly created topology. -3. Compare the results against the base scenario. - -::: - -Repeat this approach until you are satisfied with your design. - -## Epilogue - -In this tutorial, you should have learned briefly about what datacenters are, and the process of designing and -optimizing a datacenter yourself. If you have any feedback (positive or negative) about your experience using OpenDC -during this tutorial, please let us know! diff --git a/site/old_files/old_tutorials/0-installation.md b/site/old_files/old_tutorials/0-installation.md deleted file mode 100644 index 281e811..0000000 --- a/site/old_files/old_tutorials/0-installation.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -description: How to install OpenDC locally, and start experimenting in no time. ---- - -# Installation - -This page describes how to set up and configure a local single-user OpenDC installation so that you can quickly get your -experiments running. You can also use the [hosted version of OpenDC](https://app.opendc.org) to get started even -quicker (The web server is however missing some more complex features). - - -## Prerequisites - -1. **Supported Platforms** - OpenDC is actively tested on Windows, macOS and GNU/Linux. -2. **Required Software** - A Java installation of version 19 or higher is required for OpenDC. You may download the - [Java distribution from Oracle](https://www.oracle.com/java/technologies/downloads/) or use the distribution provided - by your package manager. - -## Download - -To get an OpenDC distribution, download a recent version from our [Releases](https://github.com/atlarge-research/opendc/releases) page on GitHub. -For basic usage, the OpenDCExperimentRunner is all that is needed. - -## Setup - -Unpack the downloaded OpenDC distribution. Opening OpenDCExperimentRunner results in two folders, `bin` and `lib`. -`lib` contains all `.jar` files needed to run OpenDC. `bin` two executable versions of the OpenDCExperimentRunner. -In the following pages, we discuss how to run an experiment using the executables. - diff --git a/site/old_files/old_tutorials/1-design.mdx b/site/old_files/old_tutorials/1-design.mdx deleted file mode 100644 index e8ab2c5..0000000 --- a/site/old_files/old_tutorials/1-design.mdx +++ /dev/null @@ -1,154 +0,0 @@ ---- -description: How to design a virtual datacenter in OpenDC from scratch. ---- - -# Design a Datacenter - -Now that you have installed OpenDC (or are using the hosted version), we will start designing a (virtual) datacenter -in OpenDC. - -## Before we start - -There are a couple of steps we need to perform before we can start designing a datacenter in OpenDC. First, we need to -enter the OpenDC web application. This done as follows: - -
-
-
-
-
-

Hosted Deployment

- - To enter the hosted version of OpenDC, you need a user account. User management is provided - by Auth0, which allows you to login with social accounts or via - email. - -
- -
-
-
-
-
-

Local Deployment

- - The local distribution of OpenDC runs in single-user mode by default, which does not require - authentication. This allows you to quickly start designing and experimenting with new - datacenters. - -
- -
-
-
-
- -### Create a Project - -Next, we need to create a new project. Projects allow you to organize your designs and experiments together. -Click on โ€˜+ New Projectโ€™ in the right corner to open the project creation dialog. -Give your project a name and save it. You can now open it by clicking on it in the project table. If all went well, -youโ€™re redirected to your new project, and are presented with an empty project overview. - -### Create a Topology - -In OpenDC, the datacenter design is also called a **topology**. This topology represents the physical layout of a -datacenter and specifies everything from the architectural layout of the datacenterโ€™s rooms to which CPUs are in a -particular machine. - -To create a design (topology), click on โ€˜+ New Topologyโ€™ in the top right corner of the topology table. -Once you have created the topology, it will appear the topology table. By clicking on the topology, you will be -redirected to a (still empty) overview of the topology. From here, we'll start designing a datacenter. - -### Terminology - -Hereโ€™s an overview of some of the language youโ€™ll find when designing a datacenter in OpenDC: - -- **Topology**: the physical layout of your datacenter -- **Room**: a room in the datacenter -- **Tile**: one of the tiles that forms a room -- **Rack**: a rack of servers that stands on top of a tile -- **Machine**: a machine that takes up a single slot in a server rack, containing several components such as CPUs, GPUs, - network interfaces and storage drives. - -## Build the datacenter - -Open the project and topology that you have created and click on the 'Floor Plan' tab (see [Figure 1](#floor-plan)). -Weโ€™re now in datacenter construction mode. Notice the grid on the canvas? Thatโ€™s where youโ€™ll place tiles, in order to -build rooms. Letโ€™s take a moment to familiarize ourselves with the interface. - -If you dismiss the sidebar on your left, you have controls for zooming in and out. Next to the zooming buttons, you also -have a โ€˜Screenshotโ€™ button, in case you want to record the state of the canvas and export it to an image file. On the -right side of the screen, you have the simHyperVisorContext menu. This menu changes depending on your zoom level. - -As there are currently no rooms, we are in โ€˜Buildingโ€™ mode, and our only option is to โ€˜Construct a new roomโ€™. Click on -that button to build a first datacenter room - once youโ€™ve clicked on it, every tile of the canvas that you click on -becomes a tile of that room. There is one restriction though: Each tile that you add must be adjacent to any previous -tiles that you have added. You can see for yourself which tile positions are clickable through the highlight color that -is shown on hovering over them. - -
- Analysis of results reported by OpenDC -
The floor plan of a (virtual) datacenter in OpenDC.
-
- -### Create a Room - -:::note Action - -Create at least a single room, with help of the above instructions. - -::: - -Once youโ€™ve placed the tiles, you can give the room a name, if you want to. To do this, click on the room you want to -edit. Youโ€™ll notice the application going into โ€˜Roomโ€™ mode, allowing you to manipulate the topology of the datacenter at -a more fine-grained level. In the simHyperVisorContext menu, change the room name, and click on the โ€˜Saveโ€™ button. You can exit -โ€˜Roomโ€™ mode by clicking on any of the darkened areas outside of the selected room. This will bring you back to -โ€˜Buildingโ€™ mode. - -### Place Server Racks - -:::note Action - -Add at least a single rack in the room. - -::: - -Empty rooms are of no use to the stakeholders of a datacenter. They want machines! Letโ€™s place some racks in the room -to fulfill this demand. Click on the room and add some racks. To stop adding racks, click on the blue element in the -sidebar, again. - -### Fill the Racks with Servers - -:::note Action - -Add a couple of servers to the rack. - -::: - -To add actual servers to the empty racks, weโ€™ll need to go one level deeper in the topological hierarchy of the -datacenter. Clicking on a rack lets you do just that. Once youโ€™ve clicked on it, youโ€™ll notice the simHyperVisorContext menu now -displaying slots. In each slot fits exactly one server unit. To add such a server unit, click on the โ€˜Add machineโ€™ -button of that slot. -Just like in โ€˜Roomโ€™ mode, you can exit โ€˜Rackโ€™ mode by clicking on any of the darkened tiles around the currently -selected rack. - -### Add Resources to the Servers - -Weโ€™re almost done creating our datacenter! The only problem we have is that the machines / servers we just added lack -any real resources (such as CPUs, GPUs, memory cards, and disk storage). - -:::note Action - -Populate the machines with CPU and memory resources. - -::: - -To do this, click on any machine you want to edit. Notice the simHyperVisorContext menu changing, with tabs to add different kinds of -units to your machine. Have a look around as to what can be added. - -Once you are satisfied with the datacenter design, we will experiment with the design in the next chapter. diff --git a/site/old_files/old_tutorials/2-experiment.mdx b/site/old_files/old_tutorials/2-experiment.mdx deleted file mode 100644 index 14970ea..0000000 --- a/site/old_files/old_tutorials/2-experiment.mdx +++ /dev/null @@ -1,74 +0,0 @@ ---- -description: How to experiment with your datacenter designs. ---- - -# Create an Experiment - -After designing a datacenter in OpenDC, the next step is to experiment with the design using OpenDC's built-in -simulator, in order to analyze its performance and compare it against other designs. - -In OpenDC, we use the concept of portfolios of scenarios to experiment with datacenter designs. In the next sections, we -will explain how you can use these powerful concepts for the experimental analysis of your designs. - -## Create a Portfolio -OpenDC organizes multiple scenarios (experiments) into a **portfolio**. Each portfolio is composed of a base scenario, -a set of candidate scenarios given by the user and a set of targets (e.g., metrics) used to compare scenarios. - -To create a new portfolio, open a project in the OpenDC web interface and click on โ€˜+ New Portfolioโ€™ in the top right -corner of the portfolio table. This opens a modal with the following options: - -1. **Name**: the name of your portfolio. -2. **Metrics**: the metrics that you want to use to compare the scenarios in the portfolio. -3. **Repeats per Scenario**: the number of times each scenario should be simulated (to account for variance). - -## Create Scenarios - -A **scenario** represents a point in the datacenter design space that should be explored. It consists of a combination -of workload, topology, and a set of operational phenomena. Phenomena can include correlated failures, performance -variability, security breaches, etc., allowing the scenarios to more accurately capture the real-world operations. - -The baseline for comparison in a portfolio is the **base scenario**. It represents the status quo of the infrastructure -or, when planning infrastructure from scratch, it consists of very simple base workloads and topologies. -The other scenarios in a portfolio, called the **candidate scenarios**, represent changes to the configuration -that might be of interest to the datacenter designer. Dividing scenarios into these two categories ensures that any -comparative insights provided by OpenDC are meaningful within the simHyperVisorContext of the current architecture. - -To create a new scenario, open a portfolio in the OpenDC web interface and click on โ€˜+ New Scenarioโ€™ in the top right -corner of the scenario table. This opens a modal with the following options (as shown in [Figure 1](#explore)): - -1. **Name**: the name of your scenario. The first scenario of a portfolio is always called the _Base scenario_. -2. **Workload**: the applications (e.g., virtual machines, workflows, functions) that consume resources in your - datacenter. - 1. **Workload Trace**: A dataset that characterizes the historical runtime behavior of virtual machines in the - workload over time. - 2. **Load Sampling Fraction**: The percentage of the workload that should be simulated (e.g., 10% of virtual - machines in the workload trace). -3. **Environment**: - 1. **Topology**: one of the topologies of the project to use for the scenario. - 2. **Scheduler**: the algorithm that decides on which hosts the virtual machines should be placed. -4. **Operational Phenomena**: - 1. **Failures**: a flag to enable stochastic host failures during simulation. - 2. **Performance interference**: a flag to enable performance interference between virtual machines (only available - for a subset of traces). - -Once you have created the scenario, it will be enqueued for simulation. Usually the results of the simulation should be -available within one minute after creation. However, if there are lots of queued simulation jobs, it might take a bit -longer. - -
- Creating a new scenario in OpenDC -
Creating a new scenario in OpenDC. The user can select the topology, workload, and operational phenomena.
-
- -## Analyze Results - -After creating scenarios, the scenario table will show whether the simulation is still pending, completed successfully, -or failed for some reason. If the scenario was simulated successfully, its results will become visible on the โ€˜Resultsโ€™ -tab as shown in [Figure 2](#analysis). - -
- Analysis of results reported by OpenDC -
Plots and visual summaries generated by OpenDC comparing different scenarios.
-
- -This tab will show the selected metrics for the portfolio and allow you to compare their values for different scenarios. diff --git a/site/old_files/old_tutorials/3-whats-next.md b/site/old_files/old_tutorials/3-whats-next.md deleted file mode 100644 index 7c02111..0000000 --- a/site/old_files/old_tutorials/3-whats-next.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -description: How to supercharge your designs and experiments with OpenDC. ---- - -# What's next? - -Congratulations! You have just learned how to design and experiment with a (virtual) datacenter in OpenDC. What's next? - -- Follow one of the [tutorials](/docs/category/tutorials) using OpenDC. -- Check the [advanced guides](/docs/category/advanced-guides) for more complex material. -- Read about [existing work using OpenDC](/community/research). -- Get involved in the [OpenDC Community](/community/support). diff --git a/site/old_files/old_tutorials/_category_.json b/site/old_files/old_tutorials/_category_.json deleted file mode 100644 index 169f7a2..0000000 --- a/site/old_files/old_tutorials/_category_.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "label": "Getting Started", - "position": 2, - "link": { - "type": "generated-index", - "description": "10 minutes to learn the most important concepts of OpenDC." - } -} diff --git a/site/src/components/HomepageFeatures/api-page.png b/site/src/components/HomepageFeatures/api-page.png new file mode 100644 index 0000000..4e000fb Binary files /dev/null and b/site/src/components/HomepageFeatures/api-page.png differ diff --git a/site/src/components/HomepageFeatures/grafana-dashboard.png b/site/src/components/HomepageFeatures/grafana-dashboard.png new file mode 100644 index 0000000..0211f40 Binary files /dev/null and b/site/src/components/HomepageFeatures/grafana-dashboard.png differ diff --git a/site/src/components/HomepageFeatures/index.js b/site/src/components/HomepageFeatures/index.js index a4c4287..1cda089 100644 --- a/site/src/components/HomepageFeatures/index.js +++ b/site/src/components/HomepageFeatures/index.js @@ -3,49 +3,35 @@ import clsx from 'clsx' import styles from './styles.module.css' const FeatureList = [ - // { - // title: 'Easy to Use', - // Svg: () => Building a datacenter in OpenDC, - // description: ( - // <> - // OpenDC is designed from the ground up to be easily installed and used via its online interface to get - // your experiments running quickly. - // - // ), - // }, - // { - // title: 'Versatile Models', - // Svg: () => ( - // Explore alternative scenarios with OpenDC - // ), - // description: ( - // <> - // Explore scenarios around emerging datacenter technologies such as cloud computing,{' '} - // serverless computing, big data, and machine learning. - // - // ), - // }, { - title: 'Simplified Analysis', - Svg: () => ( - Automated plots and visual summaries generated by OpenDT + title: 'Real-Time Dashboard', + image: require('./grafana-dashboard.png').default, + alt: 'Grafana dashboard showing power consumption', + description: ( + <> + Monitor simulated vs actual power consumption in real-time through the Grafana dashboard. Track carbon + emissions and identify prediction accuracy. + ), + }, + { + title: 'REST API', + image: require('./api-page.png').default, + alt: 'OpenAPI documentation page', description: ( <> - Investigate datacenter performance using a real-time view of workloads on the OpenDT dashboard. + Query simulation data and control topology through a documented REST API. Integrate OpenDT with your + existing infrastructure and automation workflows. ), }, ] -function Feature({ Svg, title, description }) { +function Feature({ image, alt, title, description }) { return ( -
-
- +
+
+ {alt}

{title}

@@ -59,7 +45,7 @@ export default function HomepageFeatures() { return (
-
+
{FeatureList.map((props, idx) => ( ))} diff --git a/site/src/components/HomepageFeatures/screenshot-construction.png b/site/src/components/HomepageFeatures/screenshot-construction.png deleted file mode 100644 index 8e26526..0000000 Binary files a/site/src/components/HomepageFeatures/screenshot-construction.png and /dev/null differ diff --git a/site/src/components/HomepageFeatures/screenshot-explore.png b/site/src/components/HomepageFeatures/screenshot-explore.png deleted file mode 100644 index 307aaa1..0000000 Binary files a/site/src/components/HomepageFeatures/screenshot-explore.png and /dev/null differ diff --git a/site/src/components/HomepageFeatures/screenshot-opendt.png b/site/src/components/HomepageFeatures/screenshot-opendt.png deleted file mode 100644 index c986aa5..0000000 Binary files a/site/src/components/HomepageFeatures/screenshot-opendt.png and /dev/null differ diff --git a/site/src/components/HomepageFeatures/screenshot-results.png b/site/src/components/HomepageFeatures/screenshot-results.png deleted file mode 100644 index f7e5858..0000000 Binary files a/site/src/components/HomepageFeatures/screenshot-results.png and /dev/null differ diff --git a/site/src/components/HomepageFeatures/styles.module.css b/site/src/components/HomepageFeatures/styles.module.css index 509d8c7..f1804c0 100644 --- a/site/src/components/HomepageFeatures/styles.module.css +++ b/site/src/components/HomepageFeatures/styles.module.css @@ -5,7 +5,20 @@ width: 100%; } -.featureSvg { - height: 200px; - width: 200px; +.featureRow { + justify-content: center; +} + +.featureImageWrapper { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 1rem; +} + +.featureImage { + max-width: 100%; + max-height: 300px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } diff --git a/site/src/components/HomepageIntro/index.js b/site/src/components/HomepageIntro/index.js index 7d00b12..ba2aa1d 100644 --- a/site/src/components/HomepageIntro/index.js +++ b/site/src/components/HomepageIntro/index.js @@ -24,31 +24,32 @@ import clsx from 'clsx' import React from 'react' import styles from './styles.module.css' -import DatacenterImage from '@site/static/img/datacenter-drawing.png' +import OverviewImage from '@site/static/img/high-level-overview-dt-pt.png' export default function HomepageInto() { return (
-
-

The datacenter (DC) industry...

+
+

Shadow Mode Digital Twin

+

+ OpenDT bridges physical and digital infrastructure through continuous monitoring and + simulation. A human-in-the-loop approach enables SLO-oriented steering of datacenter + operations. +

    -
  • Is worth over $200 bn, and growing
  • -
  • Has many hard-to-grasp concepts
  • -
  • Needs to become accessible to many
  • +
  • Connect to real or mocked datacenters
  • +
  • Replay historical workloads at configurable speed
  • +
  • Compare predicted vs actual power in real-time
-
- Schematic top-down view of a datacenter -
-
-

OpenDT provides...

-
    -
  • Datacenter digital twin monitoring as a service
  • -
  • Human-in-the-loop feedback
  • -
  • LLM-powered optimization
  • -
+
+ High-level overview: Physical ICT Infrastructure connected to Digital ICT Infrastructure through monitoring, datagen, and SLO-oriented steering with human in the loop
diff --git a/site/src/components/HomepageIntro/styles.module.css b/site/src/components/HomepageIntro/styles.module.css index 53c6f1b..ecf9067 100644 --- a/site/src/components/HomepageIntro/styles.module.css +++ b/site/src/components/HomepageIntro/styles.module.css @@ -26,9 +26,20 @@ .intro > :global(.container) > :global(.row) { justify-content: center; + align-items: center; } .textCol { align-self: center; - max-width: 350px; +} + +.imageCol { + display: flex; + justify-content: center; + align-items: center; +} + +.overviewImage { + max-width: 100%; + max-height: 280px; } diff --git a/site/src/components/TeamMembers/index.js b/site/src/components/TeamMembers/index.js deleted file mode 100644 index 70ea675..0000000 --- a/site/src/components/TeamMembers/index.js +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright (c) 2022 AtLarge Research - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -import React from 'react' -import clsx from 'clsx' - -import styles from './styles.module.css' - -const leads = [ - { - name: 'Prof.dr.ir. Alexandru Iosup', - title: 'Project Lead', - avatar: 'https://www.atlarge-research.com/images/people/aiosup_large.png', - url: 'https://www.atlarge-research.com/aiosup/', - }, - { - name: 'Dante Niewenhuis', - title: 'Technology Lead', - avatar: "https://www.atlarge-research.com/images/people/dniewenhuis_large.png", - url: 'https://www.atlarge-research.com/dniewenhuis/', - }, - { - name: 'Fabian Mastenbroek', - title: 'Technology Lead (2020-2022)', - avatar: 'https://www.atlarge-research.com/images/people/fmastenbroek_large.png', - url: 'https://www.atlarge-research.com/fmastenbroek/', - }, - { - name: 'Georgios Andreadis', - title: 'Former Technology Lead (2018-2020)', - avatar: 'https://www.atlarge-research.com/images/people/gandreadis_large.png', - url: 'https://www.atlarge-research.com/gandreadis/', - }, - { - name: 'Vincent van Beek', - title: 'Former Technology Lead (2017-2018)', - avatar: 'https://www.atlarge-research.com/images/people/vvanbeek_large.png', - url: 'https://www.atlarge-research.com/vvanbeek/', - }, -] - -const members = [ - { - name: 'Matthijs Bijman', - avatar: 'https://www.atlarge-research.com/images/people/mbijman_large.png', - url: 'https://www.atlarge-research.com/mbijman/', - }, - { - name: 'Jaro Bosch', - avatar: 'https://www.atlarge-research.com/images/people/jbosch_large.png', - url: 'https://www.atlarge-research.com/jbosch/', - }, - { - name: 'Jacob Burley', - avatar: 'https://www.atlarge-research.com/images/people/jburley_large.png', - url: 'https://www.atlarge-research.com/jburley/', - }, - { - name: 'Erwin van Eyk', - avatar: 'https://www.atlarge-research.com/images/people/evaneyk_large.png', - url: 'https://www.atlarge-research.com/evaneyk/', - }, - { - name: 'Hongyu He', - avatar: 'https://www.atlarge-research.com/images/people/hhe_large.png', - url: 'https://www.atlarge-research.com/hhe/', - }, - { - name: 'Soufiane Jounaid', - avatar: 'https://www.atlarge-research.com/images/people/sjounaid_large.png', - url: 'https://www.atlarge-research.com/sjounaid/', - }, - { - name: 'Wenchen Lai', - avatar: 'https://www.atlarge-research.com/images/people/wlai_large.png', - url: 'https://www.atlarge-research.com/wlai/', - }, - { - name: 'Leon Overweel', - avatar: 'https://www.atlarge-research.com/images/people/loverweel_large.png', - url: 'https://www.atlarge-research.com/loverweel/', - }, - - { - name: 'Sacheendra Talluri', - avatar: 'https://www.atlarge-research.com/images/people/stalluri_large.png', - url: 'https://www.atlarge-research.com/stalluri/', - }, - { - name: 'Laurens Versluis', - avatar: 'https://www.atlarge-research.com/images/people/lfdversluis_large.png', - url: 'https://www.atlarge-research.com/lfdversluis/', - }, -] - -function TeamMember({ className, name, title, avatar, url, size = 'lg' }) { - return ( -
- - {`${name} - -
-
{name}
- {title && {title}} -
-
- ) -} - -export default function TeamMembers() { - return ( -
-
- {leads.map(({ name, title, avatar, url }) => ( - - ))} - {members.map(({ name, avatar, url }) => ( - - ))} -
-
- ) -} diff --git a/site/src/components/TeamMembers/styles.module.css b/site/src/components/TeamMembers/styles.module.css deleted file mode 100644 index 34d9135..0000000 --- a/site/src/components/TeamMembers/styles.module.css +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2022 AtLarge Research - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -.members { - justify-content: center; -} - -.member { - padding: 0.5em; -} - -.memberIntro { - justify-content: normal; - margin-top: 0.5em; -} - -.memberIntro > :global(.avatar__subtitle) { - max-width: 155px; -} diff --git a/site/src/pages/index.js b/site/src/pages/index.js index 0f56c19..459b50f 100644 --- a/site/src/pages/index.js +++ b/site/src/pages/index.js @@ -16,10 +16,10 @@ function HomepageHeader() {

{siteConfig.title}

{siteConfig.tagline}

- +
- - Getting Started with OpenDT - 10min โฑ๏ธ + + Get Started
@@ -31,8 +31,8 @@ export default function Home() { const { siteConfig } = useDocusaurusContext() return (
diff --git a/site/static/img/datacenter-drawing.png b/site/static/img/datacenter-drawing.png deleted file mode 100644 index 401168e..0000000 Binary files a/site/static/img/datacenter-drawing.png and /dev/null differ diff --git a/site/static/img/design_opendt_detailed.png b/site/static/img/design_opendt_detailed.png new file mode 100644 index 0000000..73eac6c Binary files /dev/null and b/site/static/img/design_opendt_detailed.png differ diff --git a/site/static/img/design_opendt_hl.png b/site/static/img/design_opendt_hl.png new file mode 100644 index 0000000..6537abb Binary files /dev/null and b/site/static/img/design_opendt_hl.png differ diff --git a/site/static/img/failureModels.png b/site/static/img/failureModels.png deleted file mode 100644 index 5ad3a85..0000000 Binary files a/site/static/img/failureModels.png and /dev/null differ diff --git a/site/static/img/high-level-overview-dt-pt.png b/site/static/img/high-level-overview-dt-pt.png new file mode 100644 index 0000000..e876d42 Binary files /dev/null and b/site/static/img/high-level-overview-dt-pt.png differ