Repository: NREL/HOPP (language: python)

Issue:
Update to latest floris

Test floris
<!--
IMPORTANT NOTES

Is this pull request ready to be merged?
- Do the existing tests pass and new tests added for new code?
- Is all development in a state where you are proud to share it with others and
  willing to ask other people to take the time to review it?
- Is it documented in such a way that a review can reasonably understand what you've
  done and why you've done it? Can other users understand how to use your changes?
If not but opening the pull request will facilitate development, make it a "draft" pull request

This form is written in GitHub's Markdown format. For a reference on this type
of syntax, see GitHub's documentation:
https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax

This template contains guidance for your submission within the < ! - -, - - > blocks.
These are comments in HTML syntax and will not appear in the submission.
Be sure to use the "Preview" feature on GitHub to ensure your submission is formatted as intended.

When including code snippets, please paste the text itself and wrap the code block with
ticks (see the other character on the tilde ~ key in a US keyboard) to format it as code.
For example, Python code should be wrapped in ticks like this:
```python
def a_func():
    return 1

a = 1
b = a_func()
print(a + b)
```
This is preferred over screen shots since it is searchable and others can copy/paste
the text to run it.
-->


# Add tests for FLORIS
<!--
Be sure to title your pull request so that readers can scan through the list of PR's and understand
what this one involves. It should be a few well selected words to get the point across. If you have
a hard time choosing a brief title, consider splitting the pull request into multiple pull requests.
Keep in mind that the title will be automatically included in the release notes.
-->
This PR adds tests for the FLORIS capabilities in HOPP. The tests do not have meaningful values other than what I got when running the code locally. This PR also updates the hybrid tests for wind only with a new tolerance because the existing tolerance was so large as to be meaningless. 

## Related issue
<!--
If one exists, link to a related GitHub Issue.
-->
#357 

## Impacted areas of the software
<!--
List any modules or other areas which should be impacted by this pull request. This helps to
determine the verification tests.
-->
`tests/hopp/test_wind.py`
`tests/hopp/test_hybrid.py`

## Additional supporting information
<!--
Add any other context about the problem here.
-->
This PR is a critical step towards updating the FLORIS version used by HOPP.

## Test results, if applicable
<!--
Add the results from unit tests and regression tests here along with justification for any
failing test cases.
-->
All tests are passing locally.

<!--
__ For NREL use __
Release checklist:
- Update the version in
    - [ ] README.md
    - [ ] hopp/VERSION
- [ ] Verify docs builds correctly
- [ ] Create a tag in the NREL/HOPP repository
-->



Discussion / hints:

Fix patch:
diff --git a/hopp/simulation/technologies/sites/site_info.py b/hopp/simulation/technologies/sites/site_info.py
index 4ec70d59a..fadb14b74 100644
--- a/hopp/simulation/technologies/sites/site_info.py
+++ b/hopp/simulation/technologies/sites/site_info.py
@@ -5,10 +5,10 @@
 import matplotlib.pyplot as plt
 import numpy as np
 from numpy.typing import NDArray
-from shapely.geometry import Polygon, MultiPolygon, Point 
+from shapely.geometry import Polygon, MultiPolygon, Point, shape
 from shapely.geometry.base import BaseGeometry
 from shapely.ops import transform
-from shapely.validation import make_valid
+from shapely import make_valid
 from fastkml import kml, KML
 import pyproj
 import utm
@@ -259,7 +259,8 @@ def kml_read(filepath):
         valid_region = None
         for pm in placemarks:
             if "boundary" in pm.name.lower():
-                valid_region = make_valid(pm.geometry)
+                shapely_object = shape(pm.geometry)
+                valid_region = make_valid(shapely_object)
                 lon, lat = valid_region.centroid.x, valid_region.centroid.y
                 if project is None:
                     zone_num = utm.from_latlon(lat, lon)[2]
@@ -272,9 +273,9 @@ def kml_read(filepath):
         for pm in placemarks:
             if 'exclusion' in pm.name.lower():
                 try:
-                    valid_region = valid_region.difference(transform(project, pm.geometry.buffer(0)))
+                    valid_region = valid_region.difference(transform(project, shape(pm.geometry.buffer(0))))
                 except:
-                    valid_region = valid_region.difference(transform(project, make_valid(pm.geometry)))
+                    valid_region = valid_region.difference(transform(project, make_valid(shape(pm.geometry))))
         return k, valid_region, lat, lon
 
     @staticmethod
diff --git a/hopp/simulation/technologies/wind/floris.py b/hopp/simulation/technologies/wind/floris.py
index e26e7a116..a507c706e 100644
--- a/hopp/simulation/technologies/wind/floris.py
+++ b/hopp/simulation/technologies/wind/floris.py
@@ -1,10 +1,11 @@
 # tools to add floris to the hybrid simulation class
 from attrs import define, field
+from dataclasses import dataclass, asdict
 import csv
 from typing import TYPE_CHECKING, Tuple
 import numpy as np
 
-from floris.tools import FlorisInterface
+from floris import FlorisModel, TimeSeries
 
 from hopp.simulation.base import BaseClass
 from hopp.simulation.technologies.sites import SiteInfo
@@ -20,8 +21,10 @@ class Floris(BaseClass):
     site: SiteInfo = field()
     config: "WindConfig" = field()
 
+    _operational_losses: float = field(init=False)
     _timestep: Tuple[int, int] = field(init=False)
-    fi: FlorisInterface = field(init=False)
+    annual_energy_pre_curtailment_ac: float = field(init=False)
+    fi: FlorisModel = field(init=False)
 
     def __attrs_post_init__(self):
         # floris_input_file = resource_file_converter(self.config["simulation_input_file"])
@@ -34,8 +37,9 @@ def __attrs_post_init__(self):
 
         # the above change is a temporary patch to bridge to refactor floris
 
-        self.fi = FlorisInterface(floris_input_file)
+        self.fi = FlorisModel(floris_input_file)
         self._timestep = self.config.timestep
+        self._operational_losses = self.config.operational_losses
 
         self.wind_resource_data = self.site.wind_resource.data
         self.speeds, self.wind_dirs = self.parse_resource_data()
@@ -52,7 +56,8 @@ def __attrs_post_init__(self):
         self.wind_farm_yCoordinates = self.fi.layout_y
         self.nTurbs = len(self.wind_farm_xCoordinates)
         self.turb_rating = self.config.turbine_rating_kw
-        self.wind_turbine_rotor_diameter = self.fi.floris.farm.rotor_diameters[0]
+        
+        self.wind_turbine_rotor_diameter = self.fi.core.farm.rotor_diameters[0]
         self.system_capacity = self.nTurbs * self.turb_rating
 
         # turbine power curve (array of kW power outputs)
@@ -121,15 +126,33 @@ def execute(self, project_life):
         power_turbines = np.zeros((self.nTurbs, 8760))
         power_farm = np.zeros(8760)
 
-        self.fi.reinitialize(wind_speeds=self.speeds[self.start_idx:self.end_idx], wind_directions=self.wind_dirs[self.start_idx:self.end_idx], time_series=True)
-        self.fi.calculate_wake()
+        time_series = TimeSeries(
+            wind_directions=self.wind_dirs[self.start_idx:self.end_idx],
+            wind_speeds=self.speeds[self.start_idx:self.end_idx],
+            turbulence_intensities=self.fi.core.flow_field.turbulence_intensities[0]
+        )
+
+        self.fi.set(wind_data=time_series)
+        self.fi.run()
 
         power_turbines[:, self.start_idx:self.end_idx] = self.fi.get_turbine_powers().reshape((self.nTurbs, self.end_idx - self.start_idx))
         power_farm[self.start_idx:self.end_idx] = self.fi.get_farm_power().reshape((self.end_idx - self.start_idx))
 
         # Adding losses from PySAM defaults (excluding turbine and wake losses)
-        self.gen = power_farm *((100 - 12.83)/100) / 1000
-        # self.gen = power_farm  / 1000
-        self.annual_energy = np.sum(self.gen)
-        print('Wind annual energy: ', self.annual_energy)
+        self.gen = power_farm * ((100 - self._operational_losses)/100) / 1000 # kW
+
+        self.annual_energy = np.sum(self.gen) # kWh
         self.capacity_factor = np.sum(self.gen) / (8760 * self.system_capacity) * 100
+        self.turb_powers = power_turbines * (100 - self._operational_losses) / 100 / 1000 # kW
+        self.turb_velocities = self.fi.turbine_average_velocities
+        self.annual_energy_pre_curtailment_ac = self.annual_energy
+
+    def export(self):
+        """
+        Return all the floris system configuration in a dictionary for the financial model
+        """
+        config = {
+            'system_capacity': self.system_capacity,
+            'annual_energy': self.annual_energy,
+        }
+        return config
\ No newline at end of file
diff --git a/hopp/simulation/technologies/wind/wind_plant.py b/hopp/simulation/technologies/wind/wind_plant.py
index 294833f36..0ed5382ba 100644
--- a/hopp/simulation/technologies/wind/wind_plant.py
+++ b/hopp/simulation/technologies/wind/wind_plant.py
@@ -8,7 +8,7 @@
 from hopp.simulation.base import BaseClass
 from hopp.type_dec import resource_file_converter
 from hopp.utilities import load_yaml
-from hopp.utilities.validators import gt_zero, contains
+from hopp.utilities.validators import gt_zero, contains, range_val
 from hopp.simulation.technologies.wind.floris import Floris
 from hopp.simulation.technologies.power_source import PowerSource
 from hopp.simulation.technologies.sites import SiteInfo
@@ -35,6 +35,7 @@ class WindConfig(BaseClass):
         layout_params: layout configuration
         rating_range_kw: allowable kw range of turbines, default is 1000 - 3000 kW
         floris_config: Floris configuration, only used if `model_name` == 'floris'
+        operational_losses: total percentage losses in addition to wake losses, defaults based on PySAM (only used for Floris model)
         timestep: Timestep (required for floris runs, otherwise optional)
         fin_model: Optional financial model. Can be any of the following:
 
@@ -43,7 +44,6 @@ class WindConfig(BaseClass):
             - a dict representing a `CustomFinancialModel`
 
             - an object representing a `CustomFinancialModel` or `Singleowner.Singleowner` instance
-
     """
     num_turbines: int = field(validator=gt_zero)
     turbine_rating_kw: float = field(validator=gt_zero)
@@ -55,6 +55,7 @@ class WindConfig(BaseClass):
     model_input_file: Optional[str] = field(default=None)
     rating_range_kw: Tuple[int, int] = field(default=(1000, 3000))
     floris_config: Optional[Union[dict, str, Path]] = field(default=None)
+    operational_losses: float = field(default = 12.83, validator=range_val(0, 100))
     timestep: Optional[Tuple[int, int]] = field(default=None)
     fin_model: Optional[Union[dict, FinancialModelType]] = field(default=None)
 
@@ -134,7 +135,7 @@ def __attrs_post_init__(self):
             self._system_model.Turbine.wind_turbine_hub_ht = self.config.hub_height
         if self.config.rotor_diameter is not None:
             self.rotor_diameter = self.config.rotor_diameter
-
+            
     @property
     def wake_model(self) -> str:
         try:
diff --git a/pyproject.toml b/pyproject.toml
index f252d5655..835c2529b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,7 +17,7 @@ dependencies = [
     "Pyomo>=6.1.2",
     "diskcache",
     "fastkml",
-    "floris<=3.6",
+    "floris>=4.0",
     "future",
     "global_land_mask",
     "humpday",
@@ -46,7 +46,7 @@ dependencies = [
     "scikit-learn",
     "scikit-optimize",
     "scipy",
-    "shapely>=1.8.5,<2.0.0",
+    "shapely",
     "setuptools",
     "timezonefinder",
     "urllib3",


Test patch:
diff --git a/tests/hopp/inputs/floris_config.yaml b/tests/hopp/inputs/floris_config.yaml
index 26d5d58f3..8e85254a0 100644
--- a/tests/hopp/inputs/floris_config.yaml
+++ b/tests/hopp/inputs/floris_config.yaml
@@ -1,7 +1,6 @@
 name: Gauss
 description: Three turbines using Gauss model
 floris_version: v3.0.0
-
 logging:
   console:
     enable: false
@@ -9,49 +8,42 @@ logging:
   file:
     enable: false
     level: WARNING
-
 solver:
   type: turbine_grid
   turbine_grid_points: 1
-
 farm:
   layout_x:
   - 0.0
   - 1841.0
   - 3682.0
   - 5523.0
-
   layout_y:
   - 0.0
   - 0.0
   - 0.0
   - 0.0
-
   turbine_type:
   - nrel_5MW
-
 flow_field:
   air_density: 1.225
-  reference_wind_height: -1 # -1 is code for use the hub height
-  turbulence_intensity: 0.06
+  reference_wind_height: -1
   wind_directions:
   - 270.0
   wind_shear: 0.33
   wind_speeds:
   - 8.0
   wind_veer: 0.0
-
+  turbulence_intensities:
+  - 0.06
 wake:
   model_strings:
-    combination_model: sosfs # TODO followup on why not using linear free-stream superposition? - ask Chris Bay and Gen S.
+    combination_model: sosfs
     deflection_model: gauss
     turbulence_model: crespo_hernandez
     velocity_model: gauss
-
   enable_secondary_steering: false
   enable_yaw_added_recovery: false
   enable_transverse_velocities: false
-
   wake_deflection_parameters:
     gauss:
       ad: 0.0
@@ -65,7 +57,6 @@ wake:
       ad: 0.0
       bd: 0.0
       kd: 0.05
-
   wake_velocity_parameters:
     cc:
       a_s: 0.179367259
@@ -83,10 +74,10 @@ wake:
       kb: 0.004
     jensen:
       we: 0.05
-
   wake_turbulence_parameters:
     crespo_hernandez:
       initial: 0.1
       constant: 0.5
       ai: 0.8
-      downstream: -0.32
\ No newline at end of file
+      downstream: -0.32
+  enable_active_wake_mixing: false
diff --git a/tests/hopp/test_hybrid.py b/tests/hopp/test_hybrid.py
index 6a4a5486b..3e03eb66c 100644
--- a/tests/hopp/test_hybrid.py
+++ b/tests/hopp/test_hybrid.py
@@ -430,13 +430,13 @@ def test_hybrid_wind_only_floris(hybrid_config, subtests):
     cf = hybrid_plant.capacity_factors
 
     with subtests.test("wind aep"):
-        assert aeps.wind == approx(72776380, 1e-3)
+        assert aeps.wind == approx(74149945, 1e-3)
     with subtests.test("hybrid aep"):
-        assert aeps.hybrid == approx(67118441, 1e-3)
+        assert aeps.hybrid == approx(68271657, 1e-3)
     with subtests.test("wind npv"):
-        assert npvs.wind == approx(3231853, 1e-3)
+        assert npvs.wind == approx(3592293, 1e-3)
     with subtests.test("hybrid npv"):
-        assert npvs.hybrid == approx(1804831, 1e-3)
+        assert npvs.hybrid == approx(2108687, 1e-3)
 
 def test_hybrid_pv_only(hybrid_config):
     technologies = hybrid_config["technologies"]
diff --git a/tests/hopp/test_wind.py b/tests/hopp/test_wind.py
index ec9cbc9b5..3d3b316f2 100644
--- a/tests/hopp/test_wind.py
+++ b/tests/hopp/test_wind.py
@@ -3,6 +3,7 @@
 import math
 
 import PySAM.Windpower as windpower
+from floris import FlorisModel, TimeSeries 
 
 from hopp.simulation.technologies.wind.wind_plant import WindPlant, WindConfig
 from tests.hopp.utils import create_default_site_info
@@ -43,7 +44,7 @@ def site():
                 35.75, 36, 36.25, 36.5, 36.75, 37, 37.25, 37.5, 37.75, 38, 38.25, 38.5, 38.75, 39, 39.25, 39.5, 39.75, 40)
 
 
-def test_wind_powercurve():
+def test_wind_powercurve_pysam():
     model = windpower.default("WindpowerSingleowner")
     model.Turbine.wind_turbine_rotor_diameter = 75
 
@@ -67,7 +68,7 @@ def test_wind_powercurve():
     assert all([a == b for a, b in zip(powercurve_truth, powercurve_calc)])
 
 
-def test_changing_n_turbines(site):
+def test_changing_n_turbines_pysam(site):
     # test with gridded layout
     config = WindConfig.from_dict({'num_turbines': 10, "turbine_rating_kw": 2000})
     model = WindPlant(site, config=config)
@@ -80,7 +81,7 @@ def test_changing_n_turbines(site):
     # test with row layout
 
 
-def test_changing_rotor_diam_recalc(site):
+def test_changing_rotor_diam_recalc_pysam(site):
     config = WindConfig.from_dict({'num_turbines': 10, "turbine_rating_kw": 2000})
     model = WindPlant(site, config=config)
     assert model.system_capacity_kw == 20000
@@ -91,7 +92,7 @@ def test_changing_rotor_diam_recalc(site):
         assert model.turb_rating == 2000, "new rating different when rotor diameter is " + str(d)
 
 
-def test_changing_turbine_rating(site):
+def test_changing_turbine_rating_pysam(site):
     # powercurve scaling
     config = WindConfig.from_dict({'num_turbines': 24, "turbine_rating_kw": 2000})
     model = WindPlant(site, config=config)
@@ -101,7 +102,7 @@ def test_changing_turbine_rating(site):
         assert model.system_capacity_kw == model.turb_rating * n_turbs, "system size error when rating is " + str(n)
 
 
-def test_changing_powercurve(site):
+def test_changing_powercurve_pysam(site):
     # with power curve recalculation requires diameter changes
     config = WindConfig.from_dict({'num_turbines': 24, "turbine_rating_kw": 2000})
     model = WindPlant(site, config=config)
@@ -114,7 +115,7 @@ def test_changing_powercurve(site):
         assert model.system_capacity_kw == pytest.approx(model.turb_rating * n_turbs, 0.1), "size error when rating is " + str(n)
 
 
-def test_changing_system_capacity(site):
+def test_changing_system_capacity_pysam(site):
     # adjust number of turbines, system capacity won't be exactly as requested
     config = WindConfig.from_dict({'num_turbines': 20, "turbine_rating_kw": 1000})
     model = WindPlant(site, config=config)
@@ -156,7 +157,6 @@ def test_changing_turbine_rating_floris(site):
     for n in range(1000, 3000, 150):
         model.turb_rating = n
         assert model.system_capacity_kw == model.turb_rating * n_turbs, "system size error when rating is " + str(n)
-
 def test_changing_system_capacity_floris(site):
     floris_config_path = (
         ROOT_DIR.parent / "tests" / "hopp" / "inputs" / "floris_config.yaml"
@@ -173,6 +173,4 @@ def test_changing_system_capacity_floris(site):
     model = WindPlant(site, config=config)
     for n in range(40000, 60000, 1000):
         model.system_capacity_by_rating(n)
-        assert model.system_capacity_kw == pytest.approx(n)
-
-
+        assert model.system_capacity_kw == pytest.approx(n)
\ No newline at end of file