{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "71f85f2a-423f-44d2-b80d-da9ac8d3961a",
   "metadata": {},
   "outputs": [],
   "source": [
    "import simpy\n",
    "import random\n",
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "import pandas as pd\n",
    "from enum import Enum\n",
    "import os\n",
    "import shutil\n",
    "from tqdm.notebook import tqdm\n",
    "from collections import Counter\n",
    "import math\n",
    "\n",
    "# Types of cache\n",
    "class CacheType(Enum):\n",
    "    LRU = 1\n",
    "    RANDOM_EVICTION = 2\n",
    "    TTL = 3\n",
    "\n",
    "\n",
    "# Constants\n",
    "SEED = 42\n",
    "DATABASE_OBJECTS = 100 # Number of objects in the database\n",
    "ACCESS_COUNT_LIMIT = 2000   # Total time to run the simulation\n",
    "EXPERIMENT_BASE_DIR = \"./experiments/\"\n",
    "TEMP_BASE_DIR = \"./.aoi_cache/\"\n",
    "\n",
    "ZIPF_CONSTANT = 2      # Shape parameter for the Zipf distribution (controls skewness) Needs to be: 1< \n",
    "\n",
    "# Set random seeds\n",
    "random.seed(SEED)\n",
    "np.random.seed(SEED)\n",
    "\n",
    "os.makedirs(TEMP_BASE_DIR, exist_ok=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9a37d7a3-3e11-4b89-8dce-6091dd38b16f",
   "metadata": {},
   "source": [
    "How to set certain parameters for specific scenarios\n",
    "\n",
    "\n",
    "| Name                 | Cache Capacity       | MAX_REFRESH_RATE | cache_type                | CACHE_TTL |\n",
    "| -------------------- | -------------------- | ---------------- | ------------------------- | --------- |\n",
    "| Default              | DATABASE_OBJECTS     | 1<               | CacheType.LRU             | 5         |\n",
    "| No Refresh           | DATABASE_OBJECTS     | 0                | CacheType.LRU             | 5         |\n",
    "| Infinite TTL         | DATABASE_OBJECTS / 2 | 0                | CacheType.LRU             | 0         |\n",
    "| Random Eviction (RE) | DATABASE_OBJECTS / 2 | 1<               | CacheType.RANDOM_EVICTION | 5         |\n",
    "| RE without Refresh   | DATABASE_OBJECTS / 2 | 0                | CacheType.RANDOM_EVICTION | 5         |\n",
    "\n",
    "\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "3d0ab5b1-162a-42c8-80a3-d31f763101f1",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Configuration (Just example, will be overwritten in next block\n",
    "\n",
    "CACHE_CAPACITY = DATABASE_OBJECTS   # Maximum number of objects the cache can hold\n",
    "\n",
    "# MAX_REFRESH_RATE is used as the maximum for a uniform\n",
    "# distribution for mu.\n",
    "# If MAX_REFRESH_RATE is 0, we do not do any refreshes.\n",
    "MAX_REFRESH_RATE = 0\n",
    "\n",
    "cache_type = CacheType.LRU\n",
    "\n",
    "# CACHE_TTL is used to determin which TTL to set when an\n",
    "# object is pulled into the cache\n",
    "# If CACHE_TTL is set to 0, the TTL is infinite\n",
    "CACHE_TTL = 5\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "5cea042f-e9fc-4a1e-9750-de212ca70601",
   "metadata": {},
   "outputs": [],
   "source": [
    "class Database:\n",
    "    def __init__(self):\n",
    "        # Each object now has a specific refresh rate 'mu'\n",
    "        self.data = {i: f\"Object {i}\" for i in range(1, DATABASE_OBJECTS + 1)}\n",
    "        self.lambda_values = {i: np.random.zipf(ZIPF_CONSTANT) for i in range(1, DATABASE_OBJECTS + 1)}  # Request rate 'lambda' for each object\n",
    "        # Refresh rate 'mu' for each object\n",
    "        if MAX_REFRESH_RATE == 0:\n",
    "            self.mu_values = {i: 0 for i in range(1,DATABASE_OBJECTS + 1)}    \n",
    "        else:\n",
    "            self.mu_values = {i: np.random.uniform(1, MAX_REFRESH_RATE) for i in range(1, DATABASE_OBJECTS + 1)}\n",
    "        self.next_request = {i: np.random.exponential(1/self.lambda_values[i]) for i in range(1, DATABASE_OBJECTS + 1)}\n",
    "\n",
    "\n",
    "    def get_object(self, obj_id):\n",
    "        # print(f\"[{env.now:.2f}] Database: Fetched {self.data.get(obj_id, 'Unknown')} for ID {obj_id}\")\n",
    "        return self.data.get(obj_id, None)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "499bf543-b2c6-4e4d-afcc-0a6665ce3ae1",
   "metadata": {},
   "outputs": [],
   "source": [
    "class Cache:\n",
    "    def __init__(self, env, db, cache_type):\n",
    "        self.cache_type = cache_type\n",
    "        self.env = env\n",
    "        self.db = db\n",
    "        self.storage = {}  # Dictionary to store cached objects\n",
    "        self.ttl = {}      # Dictionary to store TTLs\n",
    "        self.initial_fetch = {}      # Dictionary to store when an object was fetched from the databse to determine the age\n",
    "        self.cache_size_over_time = []  # To record cache state at each interval\n",
    "        self.cache_next_request_over_time = []\n",
    "        self.request_log = {i: [] for i in range(1, DATABASE_OBJECTS + 1)}\n",
    "        self.hits = {i: 0 for i in range(1, DATABASE_OBJECTS + 1)}  # Track hits per object\n",
    "        self.misses = {i: 0 for i in range(1, DATABASE_OBJECTS + 1)}  # Track misses per object\n",
    "        self.cumulative_age = {i: 0 for i in range(1, DATABASE_OBJECTS + 1)}  # Track cumulative age per object\n",
    "        self.access_count = {i: 0 for i in range(1, DATABASE_OBJECTS + 1)}  # Track access count per object\n",
    "        self.next_refresh = {}  # Track the next refresh time for each cached object\n",
    "        self.object_start_time = {} # Used as helper variable to determine the starting time of an object in the cache\n",
    "        self.cumulative_cache_time = {i: 0 for i in range(1, DATABASE_OBJECTS + 1)} # Stores the cumulative time the object has spent between its eviction and when it was first pulled into the cache\n",
    "        \n",
    "    def get(self, obj_id):\n",
    "        if obj_id in self.storage:\n",
    "            # Cache hit: Refresh TTL if TTL-Cache\n",
    "            if self.cache_type == CacheType.TTL:\n",
    "                if self.ttl[obj_id] > env.now:\n",
    "                    self.ttl[obj_id] = env.now + CACHE_TTL\n",
    "                    \n",
    "            # Cache hit: increment hit count and update cumulative age\n",
    "            self.hits[obj_id] += 1\n",
    "            self.access_count[obj_id] += 1\n",
    "            \n",
    "            self.cumulative_age[obj_id] += (env.now - self.initial_fetch[obj_id])\n",
    "\n",
    "            # Cache hit: Refresh database object on hit\n",
    "            # self.initial_fetch[obj_id] = env.now\n",
    "        else:\n",
    "            assert obj_id not in self.storage.keys(), \"Found object in cache on miss.\"\n",
    "            assert obj_id not in self.initial_fetch.keys(), \"Found age timer on miss.\"\n",
    "            assert obj_id not in self.object_start_time.keys(), \"Found cache time ratio timer on miss.\"\n",
    "            # Cache miss: Add TTL if TTL-Cache\n",
    "            # When full cache: If non-TTL-Cache: Evict. If TTL-Cache: Don't add to Cache.\n",
    "            if self.cache_type == CacheType.TTL:\n",
    "                assert obj_id not in self.ttl.keys(), \"Found cache time ratio timer on miss.\"\n",
    "                self.ttl[obj_id] = env.now + CACHE_TTL\n",
    "            else:\n",
    "                if len(self.storage) == DATABASE_OBJECTS:\n",
    "                    if self.cache_type == CacheType.LRU:\n",
    "                        self.evict_oldest()\n",
    "                    elif self.cache_type == CacheType.RANDOM_EVICTION:\n",
    "                        self.evict_random()\n",
    "                    elif self.cache-type == CacheType.TTL:\n",
    "                        return\n",
    "            \n",
    "            # Cache miss: increment miss count\n",
    "            self.misses[obj_id] += 1\n",
    "            self.access_count[obj_id] += 1\n",
    "            \n",
    "            # Cache miss: Fetch the object from the database\n",
    "            self.storage[obj_id] = self.db.get_object(obj_id)\n",
    "            self.object_start_time[obj_id] = env.now\n",
    "            \n",
    "            self.initial_fetch[obj_id] = env.now\n",
    "            self.cumulative_age[obj_id] += (env.now - self.initial_fetch[obj_id])\n",
    "            \n",
    "            if MAX_REFRESH_RATE != 0:\n",
    "                self.next_refresh[obj_id] = env.now + np.random.exponential(1/self.db.mu_values[obj_id])  # Schedule refresh\n",
    "        \n",
    "    def evict_oldest(self):\n",
    "        \"\"\"Remove the oldest item from the cache to make space.\"\"\"\n",
    "        oldest_id = min(self.initial_fetch, key=self.initial_fetch.get)  # Find the oldest item by age\n",
    "        print(f\"[{env.now:.2f}] Cache: Evicting oldest object {oldest_id} to make space at {self.ttl[oldest_id]:.2f}\")\n",
    "        self.cumulative_cache_time[obj_id] += (env.now - self.object_start_time[obj_id])\n",
    "        del self.storage[oldest_id]\n",
    "        del self.initial_fetch[oldest_id]\n",
    "        del self.object_start_time[obj_id]\n",
    "\n",
    "    def evict_random(self):\n",
    "        \"\"\"Remove a random item from the cache to make space.\"\"\"\n",
    "        random_id = np.random.choice(list(self.storage.keys()))  # Select a random key from the cache\n",
    "        print(f\"[{env.now:.2f}] Cache: Evicting random object {random_id} to make space at {self.ttl[random_id]:.2f}\")\n",
    "        self.cumulative_cache_time[obj_id] += (env.now - self.object_start_time[obj_id])\n",
    "        del self.storage[random_id]\n",
    "        del self.initial_fetch[random_id]\n",
    "        del self.object_start_time[obj_id]\n",
    "        \n",
    "    def refresh_object(self, obj_id):\n",
    "        \"\"\"Refresh the object from the database to keep it up-to-date. TTL is increased on refresh.\"\"\"\n",
    "        obj = self.db.get_object(obj_id)\n",
    "        self.storage[obj_id] = obj\n",
    "        if self.cache_type == CacheType.TTL:\n",
    "            self.ttl[obj_id] = env.now + CACHE_TTL\n",
    "        self.cumulative_cache_time[obj_id] += (env.now - self.object_start_time[obj_id])\n",
    "        # print(f\"[{env.now:.2f}] Cache: Refreshed object {obj_id}\")\n",
    "        \n",
    "    def check_expired(self):\n",
    "        \"\"\"Increment age of each cached object.\"\"\"\n",
    "        if self.cache_type == CacheType.TTL:\n",
    "            for obj_id in list(self.ttl.keys()):\n",
    "                if self.ttl[obj_id] <= env.now:\n",
    "                    # Remove object if its TTL expired\n",
    "                    # print(f\"[{env.now:.2f}] Cache: Object {obj_id} expired\")\n",
    "                    self.cumulative_cache_time[obj_id] += (env.now - self.object_start_time[obj_id])\n",
    "                    del self.storage[obj_id]\n",
    "                    del self.ttl[obj_id]\n",
    "                    del self.initial_fetch[obj_id]\n",
    "                    del self.object_start_time[obj_id]\n",
    "\n",
    "                \n",
    "    def record_cache_state(self):\n",
    "        \"\"\"Record the current cache state (number of objects in cache) over time.\"\"\"\n",
    "        self.cache_size_over_time.append((env.now, len(self.storage)))\n",
    "        self.cache_next_request_over_time.append((env.now, self.db.next_request.copy()))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "7286d498-aa6c-4efb-bb28-fe29736eab64",
   "metadata": {},
   "outputs": [],
   "source": [
    "def age_cache_process(env, cache):\n",
    "    \"\"\"Process that ages cache objects over time, removes expired items, and refreshes based on object-specific intervals.\"\"\"\n",
    "    while True:\n",
    "        if cache.cache_type == CacheType.TTL:\n",
    "            cache.check_expired()  # Remove expired objects\n",
    "\n",
    "        if MAX_REFRESH_RATE != 0:\n",
    "            # Refresh objects based on their individual refresh intervals\n",
    "            for obj_id in list(cache.storage.keys()):\n",
    "                # Check if it's time to refresh this object based on next_refresh\n",
    "                if env.now >= cache.next_refresh[obj_id]:\n",
    "                    cache.refresh_object(obj_id)\n",
    "                    # Schedule the next refresh based on the object's mu\n",
    "                    cache.next_refresh[obj_id] = env.now + np.random.exponential(1/cache.db.mu_values[obj_id])\n",
    "        \n",
    "        cache.record_cache_state()  # Record cache state at each time step\n",
    "        yield env.timeout(0.05)  # Run every second"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "687f5634-8edf-4337-b42f-bbb292d47f0f",
   "metadata": {},
   "outputs": [],
   "source": [
    "def client_request_process(env, cache, event):\n",
    "    \"\"\"Client process that makes requests for objects from the cache.\"\"\"\n",
    "    last_print = 0\n",
    "    with tqdm(total=ACCESS_COUNT_LIMIT, desc=\"Progress\", leave=True) as pbar:\n",
    "        while True:\n",
    "            obj_id, next_request = min(cache.db.next_request.items(), key=lambda x: x[1])\n",
    "            yield env.timeout(next_request - env.now)\n",
    "\n",
    "            # For progress bar\n",
    "            if (int(env.now) % 1) == 0 and int(env.now) != last_print:\n",
    "                last_print = int(env.now)\n",
    "                pbar.n = min(cache.access_count.values())\n",
    "                pbar.refresh()\n",
    "                \n",
    "            if env.now >= next_request:\n",
    "                # print(f\"[{env.now:.2f}] Client: Requesting object {obj_id}\")\n",
    "                cache.get(obj_id)\n",
    "                \n",
    "                # print(f\"[{env.now:.2f}] Client: Schedule next request for {obj_id}\")\n",
    "                next_request = env.now + np.random.exponential(1/cache.db.lambda_values[obj_id])\n",
    "                cache.request_log[obj_id].append(next_request)\n",
    "                cache.db.next_request[obj_id] = next_request\n",
    "                \n",
    "            # Simulation stop condition\n",
    "            if all(access_count >= ACCESS_COUNT_LIMIT for access_count in cache.access_count.values()):\n",
    "                print(f\"Simulation ended after {env.now} seconds.\")\n",
    "                for obj_id in cache.storage.keys():\n",
    "                    cache.cumulative_cache_time[obj_id] += (env.now - cache.object_start_time[obj_id])\n",
    "                event.succeed()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "342d1932-94b5-4e0b-89b5-d4edaaccc59c",
   "metadata": {},
   "outputs": [],
   "source": [
    "configurations = {\n",
    "    \"default\": (100, 100, 10, CacheType.LRU, 5),\n",
    "    \"No Refresh\": (100, 100, 0, CacheType.LRU, 5),\n",
    "    \"Infinite TTL\": (100, 50, 0, CacheType.LRU, 0),\n",
    "    \"Random Eviction\": (100, 50, 10, CacheType.RANDOM_EVICTION, 5),\n",
    "    \"RE without Refresh\": (100, 50, 0, CacheType.RANDOM_EVICTION, 5),\n",
    "    \"No Refresh (0.5s ttl)\": (100, 100, 0, CacheType.TTL, 0.5),\n",
    "    \"No Refresh (1.0s ttl)\": (100, 100, 0, CacheType.TTL, 1),\n",
    "    \"No Refresh (2.0s ttl)\": (100, 100, 0, CacheType.TTL, 2),\n",
    "    \"No Refresh (3.0s ttl)\": (100, 100, 0, CacheType.TTL, 3),\n",
    "    \"No Refresh (4.0s ttl)\": (100, 100, 0, CacheType.TTL, 4),\n",
    "    \"No Refresh (5.0s ttl)\": (100, 100, 0, CacheType.TTL, 5),\n",
    "    \"No Refresh (3000000000.0s ttl)\": (10, 10, 0, CacheType.TTL, 3*10**9),\n",
    "}\n",
    "\n",
    "experiments = configurations.keys()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "6b9680ed-19bc-4f5b-8f22-95ffb319f068",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/plain": [
       "['No Refresh (0.5s ttl)',\n",
       " 'No Refresh (1.0s ttl)',\n",
       " 'No Refresh (2.0s ttl)',\n",
       " 'No Refresh (3.0s ttl)',\n",
       " 'No Refresh (4.0s ttl)',\n",
       " 'No Refresh (5.0s ttl)',\n",
       " 'No Refresh (3000000000.0s ttl)']"
      ]
     },
     "execution_count": 8,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "experiments = [e for e in experiments if \"ttl)\" in e]\n",
    "experiments"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "3ff299ca-ec65-453b-b167-9a0f7728a207",
   "metadata": {
    "scrolled": true
   },
   "outputs": [
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "fced7d1a80eb44a4bbcdc954613cd1df",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "  0%|          | 0/7 [00:00<?, ?it/s]"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "7c2e5d5b0e95440db64c60574ee70814",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "Progress:   0%|          | 0/2000 [00:00<?, ?it/s]"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Simulation ended after 2114.009548152859 seconds.\n"
     ]
    },
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "f0f2b7f5bb4e44cfa7bd283233d2780c",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "Progress:   0%|          | 0/2000 [00:00<?, ?it/s]"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Simulation ended after 2100.897853255234 seconds.\n"
     ]
    },
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "4ea41abed3214a3b8574c5c906591b6b",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "Progress:   0%|          | 0/2000 [00:00<?, ?it/s]"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Simulation ended after 2132.0466495454775 seconds.\n"
     ]
    },
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "d3165cef4d714c3185fb932d982ae89c",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "Progress:   0%|          | 0/2000 [00:00<?, ?it/s]"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Simulation ended after 2175.496284542724 seconds.\n"
     ]
    },
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "96c7c17df7204b77ae7de5009bf3ae99",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "Progress:   0%|          | 0/2000 [00:00<?, ?it/s]"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Simulation ended after 2082.541010673201 seconds.\n"
     ]
    },
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "11d3d41daad44efc8858c132781b8651",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "Progress:   0%|          | 0/2000 [00:00<?, ?it/s]"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Simulation ended after 2090.8177770514644 seconds.\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "<timed exec>:81: RuntimeWarning: More than 20 figures have been opened. Figures created through the pyplot interface (`matplotlib.pyplot.figure`) are retained until explicitly closed and may consume too much memory. (To control this warning, see the rcParam `figure.max_open_warning`). Consider using `matplotlib.pyplot.close()`.\n"
     ]
    },
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "66ee0547c4ef4f5498bed684c60d18e5",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "Progress:   0%|          | 0/2000 [00:00<?, ?it/s]"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "IOPub message rate exceeded.\n",
      "The Jupyter server will temporarily stop sending output\n",
      "to the client in order to avoid crashing it.\n",
      "To change this limit, set the config variable\n",
      "`--ServerApp.iopub_msg_rate_limit`.\n",
      "\n",
      "Current values:\n",
      "ServerApp.iopub_msg_rate_limit=1000.0 (msgs/sec)\n",
      "ServerApp.rate_limit_window=3.0 (secs)\n",
      "\n"
     ]
    },
    {
     "data": {
      "image/png": "[encoded data, 32344 characters]",
      "text/plain": [
       "<Figure size 800x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 23424 characters]",
      "text/plain": [
       "<Figure size 800x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 74264 characters]",
      "text/plain": [
       "<Figure size 3000x500 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 22896 characters]",
      "text/plain": [
       "<Figure size 800x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 30000 characters]",
      "text/plain": [
       "<Figure size 800x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 23424 characters]",
      "text/plain": [
       "<Figure size 800x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 36892 characters]",
      "text/plain": [
       "<Figure size 3000x500 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 20072 characters]",
      "text/plain": [
       "<Figure size 800x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 31020 characters]",
      "text/plain": [
       "<Figure size 800x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "data": {
      "image/png": "[encoded data, 21788 characters]",
      "text/plain": [
       "<Figure size 800x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "%%time\n",
    "for experiment_name in tqdm(experiments):\n",
    "    config = configurations[experiment_name]\n",
    "\n",
    "    DATABASE_OBJECTS = config[0]\n",
    "    CACHE_CAPACITY = config[1]\n",
    "    MAX_REFRESH_RATE = config[2]\n",
    "    cache_type = config[3]\n",
    "    CACHE_TTL = config[4]\n",
    "    \n",
    "    if cache_type == CacheType.TTL:\n",
    "        assert CACHE_TTL > 0, \"Needs CACHE_TTL to be greater than 0 when using TTL-Cache.\"\n",
    "        assert CACHE_CAPACITY >= DATABASE_OBJECTS, \"Cache Size needs to be greater or equal to the amount of Database Objects.\"\n",
    "\n",
    "    # Initialize simulation environment\n",
    "    env = simpy.Environment()\n",
    "    \n",
    "    # Instantiate components\n",
    "    db = Database()\n",
    "    cache = Cache(env, db, cache_type)\n",
    "    stop_event = env.event()\n",
    "\n",
    "    # Start processes\n",
    "    env.process(age_cache_process(env, cache))\n",
    "    env.process(client_request_process(env, cache, stop_event))\n",
    "    \n",
    "    # Run the simulation\n",
    "    env.run(until=stop_event)\n",
    "    simulation_end_time = env.now\n",
    "\n",
    "    statistics = []\n",
    "    # Calculate and print hit rate and average age for each object\n",
    "    for obj_id in range(1, DATABASE_OBJECTS + 1):\n",
    "        if cache.access_count[obj_id] != 0:\n",
    "            hit_rate = cache.hits[obj_id] / max(1, cache.access_count[obj_id])  # Avoid division by zero\n",
    "            expected_hit_rate = 1-math.exp(-db.lambda_values[obj_id]*CACHE_TTL)\n",
    "            avg_cache_time = cache.cumulative_cache_time[obj_id] / max(1, simulation_end_time)  # Only average over hits\n",
    "            avg_age = cache.cumulative_age[obj_id] / max(1, cache.access_count[obj_id])\n",
    "            expected_age = hit_rate / (db.lambda_values[obj_id] * (1 - pow(hit_rate,2)))\n",
    "            # print(f\"Object {obj_id}: Hit Rate = {hit_rate:.2f}, Expected Hit Rate = {expected_hit_rate:.2f}, Average Time spend in Cache: {avg_cache_time:.2f}, Average Age = {avg_age:.2f}, Expected Age = {expected_age:.2f}\")\n",
    "            statistics.append({\"obj_id\": obj_id,\"hit_rate\": hit_rate, \"expected_hit_rate\": expected_hit_rate, \"avg_cache_time\":avg_cache_time, \"avg_age\": avg_age, \"expected_age\": expected_age})\n",
    "        \n",
    "    stats = pd.DataFrame(statistics)\n",
    "    stats.to_csv(f\"{TEMP_BASE_DIR}/hit_age.csv\",index=False)\n",
    "    stats.drop(\"obj_id\", axis=1).describe().to_csv(f\"{TEMP_BASE_DIR}/overall_hit_age.csv\")\n",
    "\n",
    "    access_count = pd.DataFrame.from_dict(cache.access_count, orient='index', columns=['access_count'])\n",
    "    hits = pd.DataFrame.from_dict(cache.hits, orient='index', columns=['hits'])\n",
    "    misses = pd.DataFrame.from_dict(cache.misses, orient='index', columns=['misses'])\n",
    "    mu = pd.DataFrame.from_dict(db.mu_values, orient='index', columns=['mu'])\n",
    "    lmbda = pd.DataFrame.from_dict(db.lambda_values, orient='index', columns=['lambda'])\n",
    "    \n",
    "    hit_rate = pd.DataFrame(stats['hit_rate'])\n",
    "    hit_rate.index = range(1,DATABASE_OBJECTS + 1)\n",
    "    expected_hit_rate = pd.DataFrame(stats['expected_hit_rate'])\n",
    "    expected_hit_rate.index = range(1,DATABASE_OBJECTS + 1)\n",
    "    expected_hit_rate_delta = pd.DataFrame((hit_rate.to_numpy()-expected_hit_rate.to_numpy()), columns=['expected_hit_rate_delta'])\n",
    "    expected_hit_rate_delta.index = range(1,DATABASE_OBJECTS + 1)\n",
    "    avg_cache_time = pd.DataFrame(stats['avg_cache_time'])\n",
    "    avg_cache_time.index = range(1,DATABASE_OBJECTS + 1)\n",
    "    cache_time_delta = pd.DataFrame((hit_rate.to_numpy()-avg_cache_time.to_numpy()), columns=['cache_time_delta'])\n",
    "    cache_time_delta.index = range(1,DATABASE_OBJECTS + 1)\n",
    "    \n",
    "    avg_age = pd.DataFrame(stats['avg_age'])\n",
    "    avg_age.index = range(1,DATABASE_OBJECTS + 1)\n",
    "    expected_age = pd.DataFrame(stats['expected_age'])\n",
    "    expected_age.index = range(1,DATABASE_OBJECTS + 1)\n",
    "    age_delta = pd.DataFrame((avg_age.to_numpy()-expected_age.to_numpy()), columns=['age_delta'])\n",
    "    age_delta.index = range(1,DATABASE_OBJECTS + 1)\n",
    "    \n",
    "    merged = access_count.merge(hits, left_index=True, right_index=True).merge(misses, left_index=True, right_index=True) \\\n",
    "        .merge(mu, left_index=True, right_index=True).merge(lmbda, left_index=True, right_index=True) \\\n",
    "        .merge(hit_rate, left_index=True, right_index=True).merge(expected_hit_rate, left_index=True, right_index=True).merge(expected_hit_rate_delta, left_index=True, right_index=True) \\\n",
    "        .merge(avg_cache_time, left_index=True, right_index=True).merge(cache_time_delta, left_index=True, right_index=True) \\\n",
    "        .merge(avg_age, left_index=True, right_index=True).merge(expected_age, left_index=True, right_index=True).merge(age_delta, left_index=True, right_index=True)\n",
    "    merged.to_csv(f\"{TEMP_BASE_DIR}/details.csv\", index_label=\"obj_id\")\n",
    "\n",
    "    # Extract recorded data for plotting\n",
    "    times, cache_sizes = zip(*cache.cache_size_over_time)\n",
    "\n",
    "    # Plot the cache size over time\n",
    "    plt.figure(figsize=(30, 5))\n",
    "    plt.plot(times, cache_sizes, label=\"Objects in Cache\")\n",
    "    plt.xlabel(\"Time (s)\")\n",
    "    plt.ylabel(\"Number of Cached Objects\")\n",
    "    plt.title(\"Number of Objects in Cache Over Time\")\n",
    "    plt.legend()\n",
    "    plt.grid(True)\n",
    "    plt.savefig(f\"{TEMP_BASE_DIR}/objects_in_cache_over_time.pdf\")\n",
    "\n",
    "    # Count occurrences of each number\n",
    "    count = Counter(list(db.lambda_values.values()))\n",
    "\n",
    "    # Separate the counts into two lists for plotting\n",
    "    x = list(count.keys())  # List of unique numbers\n",
    "    y = list(count.values())  # List of their respective counts\n",
    "\n",
    "    # Plot the data\n",
    "    plt.figure(figsize=(8, 6))\n",
    "    plt.bar(x, y, color='skyblue')\n",
    "\n",
    "    # Adding labels and title\n",
    "    plt.xlabel('Number')\n",
    "    plt.ylabel('Occurrences')\n",
    "    plt.title('Occurance of each lambda in db')\n",
    "    plt.savefig(f\"{TEMP_BASE_DIR}/lambda_distribution.pdf\")\n",
    "\n",
    "    # Plotting lambda against access_count.\n",
    "    plt.figure(figsize=(8, 6))\n",
    "    plt.scatter(merged['lambda'], merged['access_count'], alpha=0.7, edgecolor='k')\n",
    "    plt.title('Lambda vs Access Count', fontsize=14)\n",
    "    plt.xlabel('Lambda', fontsize=12)\n",
    "    plt.ylabel('Access Count', fontsize=12)\n",
    "    plt.grid(alpha=0.3)\n",
    "\n",
    "    plt.savefig(f\"{TEMP_BASE_DIR}/lambda_vs_access_count.pdf\")\n",
    "    \n",
    "    # Count occurrences of each number\n",
    "    count = Counter(np.array(list(db.mu_values.values())).round(0))\n",
    "\n",
    "    # Separate the counts into two lists for plotting\n",
    "    x = list(count.keys())  # List of unique numbers\n",
    "    y = list(count.values())  # List of their respective counts\n",
    "\n",
    "    # Plot the data\n",
    "    plt.figure(figsize=(8, 6))\n",
    "    plt.bar(x, y, color='skyblue')\n",
    "\n",
    "    # Adding labels and title\n",
    "    plt.xlabel('Number')\n",
    "    plt.ylabel('Occurrences')\n",
    "    plt.title('Occurance of each mu in db (rounded)')\n",
    "\n",
    "    os.makedirs(EXPERIMENT_BASE_DIR, exist_ok=True)\n",
    "    folder_name = experiment_name.replace(\" \", \"_\").replace(\"(\", \"\").replace(\")\", \"\").replace(\".\", \"_\")\n",
    "    folder_path = os.path.join(EXPERIMENT_BASE_DIR, folder_name)\n",
    "    os.makedirs(folder_path, exist_ok=True)\n",
    "\n",
    "    file_names = os.listdir(TEMP_BASE_DIR)\n",
    "\n",
    "    for file_name in file_names:\n",
    "        shutil.move(os.path.join(TEMP_BASE_DIR, file_name), folder_path)\n",
    "\n",
    "    del env\n",
    "    del cache\n",
    "    del db"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "b0827d71-e5bd-4303-b806-a19e17b28855",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 56496 characters]",
      "text/plain": [
       "<Figure size 1000x600 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "experiments = [k.replace(\" \", \"_\").replace(\"(\", \"\").replace(\")\", \"\").replace(\".\", \"_\") for k in configurations.keys()]\n",
    "experiments = [os.path.join(\"./experiments/\",e, \"overall_hit_age.csv\") for e in experiments if \"_ttl\" in e]\n",
    "experiments = [{'ttl': '.'.join(file.split('/')[2].split(\"_\")[2:4]),'data': pd.read_csv(file)} for file in experiments]\n",
    "\n",
    "stats = [\n",
    "    {'ttl': e['ttl'],\n",
    "     'data':  {\n",
    "         \"hit_rate\": e['data']['hit_rate'][1], \n",
    "         \"avg_age\": e['data']['avg_age'][1]\n",
    "     }\n",
    "    } for e in experiments]\n",
    "\n",
    "# Extract TTL, hit_rate, and avg_age from data\n",
    "ttls = [entry['ttl'] for entry in stats]\n",
    "hit_rates = [entry['data']['hit_rate'] for entry in stats]\n",
    "avg_ages = [entry['data']['avg_age'] for entry in stats]\n",
    "\n",
    "# Plotting\n",
    "plt.figure(figsize=(10, 6))\n",
    "\n",
    "# Plot hit_rate\n",
    "plt.subplot(2, 1, 1)\n",
    "plt.plot(ttls, hit_rates, marker='o', color='b', label='Hit Rate')\n",
    "plt.xlabel('TTL (seconds)')\n",
    "plt.ylabel('Hit Rate')\n",
    "plt.title('Hit Rate vs TTL')\n",
    "plt.grid(True)\n",
    "\n",
    "# Plot avg_age\n",
    "plt.subplot(2, 1, 2)\n",
    "plt.plot(ttls, avg_ages, marker='o', color='r', label='Avg Age')\n",
    "plt.xlabel('TTL (seconds)')\n",
    "plt.ylabel('Avg Age')\n",
    "plt.title('Avg Age vs TTL')\n",
    "plt.grid(True)\n",
    "\n",
    "# Show the plots\n",
    "plt.tight_layout()\n",
    "plt.savefig(\"./experiments/hr_and_age_vs_ttl\")\n",
    "pd.DataFrame(hit_rates, columns=[\"hit_rates\"], index=ttls).to_markdown('./experiments/hit_rates.md')\n",
    "pd.DataFrame(avg_ages, columns=[\"avg_ages\"], index=ttls).to_markdown('./experiments/avg_ages.md')"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "graphs",
   "language": "python",
   "name": "graphs"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.12.7"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
