Repository: OSeMOSYS/otoole

Issue:
[Bug]: `CapitalInvestment` not being calculated 
### The Issue

When converting a result solution file to a folder of CSVs, the variable `CapitalInvestment` is not written out 

### Expected Behavior

I would expect the file `CapitalInvestment.csv` to be available in the folder of results 

### Steps To Reproduce

1. clone [simplicity](https://github.com/OSeMOSYS/simplicity)
2. create the datafile with `otoole convert csv datafile data data.txt config.yaml`
3. build the model with `glpsol -m osemosys_fast.txt -d data.txt --wlp model.lp --check`, using the [`osemosys_fast`](https://github.com/OSeMOSYS/OSeMOSYS_GNU_MathProg/blob/master/src/osemosys_fast.txt) model
4. solve the model with `cbc model.lp solve -solu model.sol`
5. get results with `otoole results cbc csv model.sol results csv data config.yaml`
6. Check the folder of results, and `CapitalInvestment.csv` is not there :( 

### Log output

```shell
INFO:otoole.results.results:No calculation method available for CapitalInvestment
DEBUG:otoole.results.results:Error calculating CapitalInvestment: "Cannot calculate CapitalInvestment due to missing data: 'DiscountRateIdv is not accessible or available'"
```


### Operating System

Linux

### What version of otoole are you running?

1.1.0

### Possible Solution

Looks like the result package can not access the data `DiscountRateIdv`. I think this is set by default to the same as the discount rate, so it may be worth exploring if this behaviour is the same when it is not by default the discount rate. 

```
param DiscountRateIdv{r in REGION, t in TECHNOLOGY}, default DiscountRate[r];
```



### Anything else?

_No response_

Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 4ebb5bb..bdc2c67 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -5,6 +5,7 @@ Changelog
 (Development) Version 1.1.3
 ===========================
 - Lock pandas to 2.1.4 or later
+- Capital Investment result calculation fixed
 
 Version 1.1.2
 =============
diff --git a/src/otoole/results/result_package.py b/src/otoole/results/result_package.py
index 255f684..a63de92 100644
--- a/src/otoole/results/result_package.py
+++ b/src/otoole/results/result_package.py
@@ -307,8 +307,11 @@ class ResultsPackage(Mapping):
             capital_cost = self["CapitalCost"]
             new_capacity = self["NewCapacity"]
             operational_life = self["OperationalLife"]
-            discount_rate = self["DiscountRate"]
-            discount_rate_idv = self["DiscountRateIdv"]
+
+            if "DiscountRateIdv" in self.keys():
+                discount_rate = self["DiscountRateIdv"]
+            else:
+                discount_rate = self["DiscountRate"]
 
             regions = self["REGION"]["VALUE"].to_list()
             technologies = self.get_unique_values_from_index(
@@ -323,10 +326,9 @@ class ResultsPackage(Mapping):
             raise KeyError(self._msg("CapitalInvestment", str(ex)))
 
         crf = capital_recovery_factor(
-            regions, technologies, discount_rate_idv, operational_life
+            regions, technologies, discount_rate, operational_life
         )
         pva = pv_annuity(regions, technologies, discount_rate, operational_life)
-
         capital_investment = capital_cost.mul(new_capacity, fill_value=0.0)
         capital_investment = capital_investment.mul(crf, fill_value=0.0).mul(
             pva, fill_value=0.0
@@ -765,22 +767,38 @@ def capital_recovery_factor(
         param CapitalRecoveryFactor{r in REGION, t in TECHNOLOGY} :=
                 (1 - (1 + DiscountRateIdv[r,t])^(-1))/(1 - (1 + DiscountRateIdv[r,t])^(-(OperationalLife[r,t])));
     """
-    if regions and technologies:
-        index = pd.MultiIndex.from_product(
-            [regions, technologies], names=["REGION", "TECHNOLOGY"]
-        )
+
+    def calc_crf(df: pd.DataFrame, operational_life: pd.Series) -> pd.Series:
+        rate = df["VALUE"] + 1
+        numerator = 1 - rate.pow(-1)
+        denominator = 1 - rate.pow(-operational_life)
+
+        return numerator / denominator
+
+    if not regions and not technologies:
+        return pd.DataFrame(
+            data=[],
+            columns=["REGION", "TECHNOLOGY", "VALUE"],
+        ).set_index(["REGION", "TECHNOLOGY"])
+
+    index = pd.MultiIndex.from_product(
+        [regions, technologies], names=["REGION", "TECHNOLOGY"]
+    )
+    if "TECHNOLOGY" in discount_rate_idv.index.names:
         crf = discount_rate_idv.reindex(index)
-        crf["RATE"] = crf["VALUE"] + 1
-        crf["NUMER"] = 1 - crf["RATE"].pow(-1)
-        crf["DENOM"] = 1 - crf["RATE"].pow(-operational_life["VALUE"])
-        crf["VALUE"] = (crf["NUMER"] / crf["DENOM"]).round(6)
-        return crf.reset_index()[["REGION", "TECHNOLOGY", "VALUE"]].set_index(
-            ["REGION", "TECHNOLOGY"]
-        )
+        crf["VALUE"] = calc_crf(crf, operational_life["VALUE"])
+
     else:
-        return pd.DataFrame([], columns=["REGION", "TECHNOLOGY", "VALUE"]).set_index(
-            ["REGION", "TECHNOLOGY"]
-        )
+        values = discount_rate_idv["VALUE"].copy()
+        crf = discount_rate_idv.reindex(index)
+        # This is a hack to get around the fact that the discount rate is
+        # indexed by REGION and not REGION, TECHNOLOGY
+        crf[:] = values
+        crf["VALUE"] = calc_crf(crf, operational_life["VALUE"])
+
+    return crf.reset_index()[["REGION", "TECHNOLOGY", "VALUE"]].set_index(
+        ["REGION", "TECHNOLOGY"]
+    )
 
 
 def pv_annuity(


Test patch:
diff --git a/tests/results/test_results_package.py b/tests/results/test_results_package.py
index 10fc872..33c784b 100644
--- a/tests/results/test_results_package.py
+++ b/tests/results/test_results_package.py
@@ -651,6 +651,24 @@ class TestCapitalRecoveryFactor:
 
         assert_frame_equal(actual, expected)
 
+    def test_crf_no_tech_discount_rate(self, region, discount_rate, operational_life):
+
+        technologies = ["GAS_EXTRACTION", "DUMMY"]
+        regions = region["VALUE"].to_list()
+        actual = capital_recovery_factor(
+            regions, technologies, discount_rate, operational_life
+        )
+
+        expected = pd.DataFrame(
+            data=[
+                ["SIMPLICITY", "GAS_EXTRACTION", 0.5121951219512197],
+                ["SIMPLICITY", "DUMMY", 0.34972244250594786],
+            ],
+            columns=["REGION", "TECHNOLOGY", "VALUE"],
+        ).set_index(["REGION", "TECHNOLOGY"])
+
+        assert_frame_equal(actual, expected)
+
 
 class TestPvAnnuity:
     def test_pva(self, region, discount_rate, operational_life):