Repository: GreenScheduler/cats

Issue:
Small ASCII 'CATS' text banner for start of STDOUT
Obviously not a critical-path priority piece of work, but I think it is really nice (striking, unique, dare I say fun), when terminal programs have a logo/banner which displays the library name at the start of the terminal output, e.g. as for or . I think we should add one for CATS, but this may be a controversial opinion, so let's treat this as a suggestion for now! (Though if there is agreement to go ahead, I can easily set this up.)

The exact ASCII art text can be chosen at a later stage, with [ASCII generator sites like this one ](https://patorjk.com/software/taag) creating the art for one to choose from, but the idea is something like this which will be seen when someone runs cats (though perhaps we can enable it to now be shown if someone specifies via come CLI or config file option):

```console
$ cats -d 90 --loc RG2                                                                      ─╯
 __     __   _____  __  
/ /`   / /\   | |  ( (` 
\_\_, /_/--\  |_|  _)_) 

WARNING:root:config file not found
WARNING:root:Unspecified carbon intensity forecast service, using carbonintensity.org.uk
Best job start time 2024-07-21 11:27:36.231307+01:00
Carbon intensity if job started now       = 85.47 gCO2eq/kWh
Carbon intensity at optimal time          = 55.16 gCO2eq/kWh

Use --format=json to get this in machine readable format
```

or some other stylistic text choice e.g:

```console
  __   _  ___ __ 
 / _| / \|_ _/ _|
( (_ | o || |\_ \
 \__||_n_||_||__/
                 
```

or:

```console
             __         
.----.---.-.|  |_.-----.
|  __|  _  ||   _|__ --|
|____|___._||____|_____|
```

etc. (showing examples on the smaller side since we don't want anything to overhwhelm/dominate the output).

Colours can also be set for standard-supported terminal colours, but we don't have to go crazy with it (not that I would mind adding a little colour, as long as it doesn't in any significant way ramp up the carbon required to run, of course). :smile:

Discussion:
@andreww: Love the idea.@sadielbartholomew: Glad to hear! I'll wait until the next team meeting to check everyone's happy, and then I can include some examples for the banner itself and get it all coded up and put up the corresponding PR.

Solution patch:
diff --git a/cats/__init__.py b/cats/__init__.py
index 24ff15f..7712e45 100644
--- a/cats/__init__.py
+++ b/cats/__init__.py
@@ -14,6 +14,7 @@
 from .configure import get_runtime_config
 from .forecast import CarbonIntensityAverageEstimate
 from .optimise_starttime import get_avg_estimates  # noqa: F401
+from .constants import CATS_ASCII_BANNER_COLOUR, CATS_ASCII_BANNER_NO_COLOUR
 
 __version__ = "1.0.0"
 
@@ -181,6 +182,17 @@ def positive_integer(string):
         type=positive_integer,
         help="Amount of memory used by the job, in GB",
     )
+    parser.add_argument(
+        "-n",
+        "--no-colour",
+        action="store_true",
+        help="Disable all terminal output colouring",
+    )
+    parser.add_argument(
+        "--no-color",
+        action="store_true",
+        help="Disable all terminal output colouring (alias to --no-colour)",
+    )
 
     return parser
 
@@ -194,18 +206,40 @@ class CATSOutput:
     location: str
     countryISO3: str
     emmissionEstimate: Optional[Estimates] = None
+    colour: bool = False
 
     def __str__(self) -> str:
-        out = f"""Best job start time {self.carbonIntensityOptimal.start}
-Carbon intensity if job started now       = {self.carbonIntensityNow.value:.2f} gCO2eq/kWh
-Carbon intensity at optimal time          = {self.carbonIntensityOptimal.value:.2f} gCO2eq/kWh"""
+        if self.colour:
+            # Default colour
+            col_normal = "\33[0m"  # reset any colour
+
+            # Colours to indicate optimal/better results
+            col_dt_opt = "\33[32m"  # green i.e. 'good' in traffic light rating
+            col_ci_opt = "\33[32m"  # green
+            col_ee_opt = "\33[32m"  # green
+
+            # Colours to indicate original and non-optimal results
+            col_ci_now = "\33[31m"  # red i.e. 'bad' in traffic light rating
+            col_ee_now = "\33[31m"  # red
+        else:
+            col_normal = ""
+            col_dt_opt = ""
+            col_ci_opt = ""
+            col_ci_now = ""
+            col_ee_now = ""
+            col_ee_opt = ""
+
+        out = f"""
+Best job start time                       = {col_dt_opt}{self.carbonIntensityOptimal.start}{col_normal}
+Carbon intensity if job started now       = {col_ci_now}{self.carbonIntensityNow.value:.2f} gCO2eq/kWh{col_normal}
+Carbon intensity at optimal time          = {col_ci_opt}{self.carbonIntensityOptimal.value:.2f} gCO2eq/kWh{col_normal}"""
 
         if self.emmissionEstimate:
             out += f"""
-Estimated emissions if job started now    = {self.emmissionEstimate.now}
-Estimated emissions at optimal time       = {self.emmissionEstimate.best} (- {self.emmissionEstimate.savings})"""
+Estimated emissions if job started now    = {col_ee_now}{self.emmissionEstimate.now}{col_normal}
+Estimated emissions at optimal time       = {col_ee_opt}{self.emmissionEstimate.best} (- {self.emmissionEstimate.savings}){col_normal}"""
 
-        out += "\n\nUse --format=json to get this in machine readable format"
+        logging.info("Use '--format=json' to get this in machine readable format")
         return out
 
     def to_json(self, dateformat: str = "", **kwargs) -> str:
@@ -221,6 +255,15 @@ def to_json(self, dateformat: str = "", **kwargs) -> str:
         return json.dumps(data, **kwargs)
 
 
+def print_banner(disable_colour):
+    """Print an ASCII art banner with the CATS title, optionally in colour.
+    """
+    if disable_colour:
+        print(CATS_ASCII_BANNER_NO_COLOUR)
+    else:
+        print(CATS_ASCII_BANNER_COLOUR)
+
+
 def schedule_at(
     output: CATSOutput, args: list[str], at_command: str = "at"
 ) -> Optional[str]:
@@ -250,6 +293,11 @@ def schedule_at(
 def main(arguments=None) -> int:
     parser = parse_arguments()
     args = parser.parse_args(arguments)
+    colour_output = args.no_colour or args.no_color
+
+    # Print CATS ASCII art banner, before any output from printing or logging
+    print_banner(colour_output)
+
     if args.command and not args.scheduler:
         print(
             "cats: To run a command with the -c or --command option, you must\n"
@@ -286,7 +334,8 @@ def main(arguments=None) -> int:
     # Find best possible average carbon intensity, along
     # with corresponding job start time.
     now_avg, best_avg = get_avg_estimates(CI_forecast, duration=duration)
-    output = CATSOutput(now_avg, best_avg, location, "GBR")
+    output = CATSOutput(
+        now_avg, best_avg, location, "GBR", colour=not colour_output)
 
     ################################
     ## Calculate carbon footprint ##
diff --git a/cats/configure.py b/cats/configure.py
index f3bb993..ba52e3c 100644
--- a/cats/configure.py
+++ b/cats/configure.py
@@ -1,12 +1,12 @@
 """This module exports a function :py:func:`configure
 <cats.configure.configure>` that processes both command line arguments
 and configuration file. This function returns a runtime configuration
-for cats to make a request to a carcon intensity forecast provider.  A
+for cats to make a request to a carbon intensity forecast provider.  A
 runtime configuration consits of:
 
 - location (postcode)
 - job duration
-- Interface to carbon intensity forecast provider (See TODO)
+- interface to carbon intensity forecast provider (See TODO)
 
 """
 
diff --git a/cats/constants.py b/cats/constants.py
index b92b572..b65c937 100644
--- a/cats/constants.py
+++ b/cats/constants.py
@@ -1 +1,20 @@
 MEMORY_POWER_PER_GB = 0.3725  # W/GB
+
+CATS_ASCII_BANNER_COLOUR = """
+\33[34mThe\33[35m.\33[34m\33[0m____ \33[35m..... \33[0m__ \33[35m.... \33[0m________ \33[35m. \33[0m______\33[35m...
+\33[35m.. \33[0m/  __)\33[35m.....\33[0m/  \\\33[35m....\33[0m(__    __)\33[35m.\33[0m)  ____)\33[35m....
+\33[35m..\33[0m|  /\33[35m.......\33[0m/    \\\33[35m......\33[0m|  |\33[35m...\33[0m(  (___\33[35m........
+\33[35m..\33[0m| |\33[34mlimate\33[35m.\33[0m/  ()  \\\33[34mware\33[35m.\33[0m|  |\33[34mask\33[35m.\33[0m\\___  \\\33[34mcheduler
+\33[35m..\33[0m|  \\__\33[35m...\33[0m|   __   |\33[35m....\33[0m|  |\33[35m....\33[0m____)  )\33[35m....
+\33[35m...\33[0m\\    )\33[35m..\33[0m|  (\33[35m..\33[0m)  |\33[35m....\33[0m|  |\33[35m...\33[0m(      (\33[35m..\33[0m
+"""
+
+# Same as above but without the colour codes, so not given custom colouring
+CATS_ASCII_BANNER_NO_COLOUR = """
+The.____ ..... __ .... ________ . ______ ...
+.. /  __)...../  \\....(__    __).)  ____)....
+..|  /......./    \\......|  |...(  (___........
+..| |limate./  ()  \\ware.|  |ask.\\___  \\cheduler
+..|  \\__...|   __   |....|  |....____)  )....
+...\\    )..|  (..)  |....|  |...(      (..
+"""


Test patch:
diff --git a/tests/test_output.py b/tests/test_output.py
index 2032bab..c72d6f3 100644
--- a/tests/test_output.py
+++ b/tests/test_output.py
@@ -33,21 +33,19 @@
     [
         (
             OUTPUT,
-            """Best job start time 2024-03-16 02:00:00
+            """
+Best job start time                       = 2024-03-16 02:00:00
 Carbon intensity if job started now       = 50.00 gCO2eq/kWh
-Carbon intensity at optimal time          = 20.00 gCO2eq/kWh
-
-Use --format=json to get this in machine readable format""",
+Carbon intensity at optimal time          = 20.00 gCO2eq/kWh""",
         ),
         (
             OUTPUT_WITH_EMISSION_ESTIMATE,
-            """Best job start time 2024-03-16 02:00:00
+            """
+Best job start time                       = 2024-03-16 02:00:00
 Carbon intensity if job started now       = 50.00 gCO2eq/kWh
 Carbon intensity at optimal time          = 20.00 gCO2eq/kWh
 Estimated emissions if job started now    = 19
-Estimated emissions at optimal time       = 9 (- 10)
-
-Use --format=json to get this in machine readable format""",
+Estimated emissions at optimal time       = 9 (- 10)""",
         ),
     ],
 )
@@ -62,13 +60,13 @@ def test_string_repr(output, expected):
             OUTPUT,
             """{"carbonIntensityNow": {"end": "2024-03-15T17:00:00", "start": "2024-03-15T16:00:00", "value": 50}, """
             """"carbonIntensityOptimal": {"end": "2024-03-16T03:00:00", "start": "2024-03-16T02:00:00", "value": 20}, """
-            """"countryISO3": "GBR", "emmissionEstimate": null, "location": "OX1"}""",
+            """"colour": false, "countryISO3": "GBR", "emmissionEstimate": null, "location": "OX1"}""",
         ),
         (
             OUTPUT_WITH_EMISSION_ESTIMATE,
             """{"carbonIntensityNow": {"end": "2024-03-15T17:00:00", "start": "2024-03-15T16:00:00", "value": 50}, """
             """"carbonIntensityOptimal": {"end": "2024-03-16T03:00:00", "start": "2024-03-16T02:00:00", "value": 20}, """
-            """"countryISO3": "GBR", "emmissionEstimate": [19, 9, 10], "location": "OX1"}""",
+            """"colour": false, "countryISO3": "GBR", "emmissionEstimate": [19, 9, 10], "location": "OX1"}""",
         ),
     ],
 )
@@ -83,13 +81,13 @@ def test_output_json(output, expected):
             OUTPUT,
             """{"carbonIntensityNow": {"end": "202403151700", "start": "202403151600", "value": 50}, """
             """"carbonIntensityOptimal": {"end": "202403160300", "start": "202403160200", "value": 20}, """
-            """"countryISO3": "GBR", "emmissionEstimate": null, "location": "OX1"}""",
+            """"colour": false, "countryISO3": "GBR", "emmissionEstimate": null, "location": "OX1"}""",
         ),
         (
             OUTPUT_WITH_EMISSION_ESTIMATE,
             """{"carbonIntensityNow": {"end": "202403151700", "start": "202403151600", "value": 50}, """
             """"carbonIntensityOptimal": {"end": "202403160300", "start": "202403160200", "value": 20}, """
-            """"countryISO3": "GBR", "emmissionEstimate": [19, 9, 10], "location": "OX1"}""",
+            """"colour": false, "countryISO3": "GBR", "emmissionEstimate": [19, 9, 10], "location": "OX1"}""",
         ),
     ],
 )