Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/basic plotting #121

Merged
merged 6 commits into from
Oct 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@
- Traveling to a system for a repair where the timing extends beyond the end of the shift, but into the next shift, is now registered as a shift delay just like travel weather delays that extend beyond the end of the current shift but before the start of the next shift. This has a small positive impact on availability because a turbine or cable may not start being repaired until weather is more consistently clear, rather than starting it and extending it for many shifts.
- `Windfarm.cable()` now correctly identifies 2 and 3 length cable naming conventions to differentiate which version of the cable id is being retrieved.
- An edge case where events occurred just after the end of a simulation has been resolved by checking the datetime stamp of that event and not adding any action log to the simulation that is after the `WombatEnvironment.end_datetime`.
- A bug in how the total wind farm operating level was calculated is updated to account for substation downtime, rather than using a sum of all turbine values.
- `Metrics.time_based_availability` and `Metrics.production_based_availability` have been updated to use to take advantage of the above fix. Similarly, the time-based availability skews higher now, as is expected when taking into account all availability greater than 0, and the energy-based availability drops moderately as a result of accounting for the substation downtime.

### General Updates

- `Metrics.equipment_labor_cost_breakdowns` now has a `by_equipment` boolean flag, so that the labor and equipment costs can be broken down by category and equipment. Additionally, `total_hours` has been added to the results, resulting in fewer computed metrics across the same set of breakdowns.
- "request canceled" and "complete" are now updated in the logging to directly state if it's a "repair" or "maintenance" task that was completed or canceled to ensure consistency across the logging messages. As a result, `Metrics.task_completion_rate()` can now correctly differentiate between the completed tasks effectively.
- The use of unique naming for the servicing equipment is now enforced to ensure that there is no overlap and potential confusion in the model.
- New, experimental plotting functionality has been added via `wombat.utilities.plot`.
- `plot_farm_layout` plots the graph layout of the farm. Note that this will not work if realistic lat/lon pairs have not been provided in the layout CSV.
- `plot_farm_availability` plots a line chart of the monthly overall windfarm availability. Additional toggles allow for the plotting of individual turbines in the background and/or the a 95% confidence interval band around the farm's availability
- `plot_detailed_availability` plots a heatmap of the hourly turbine and farm operational levels to help in debugging simulations where availability may be running suspiciously low (i.e., a turbine might have shut down or a cable failure didn't get repaired within a reasonable time frame).
- `Simulation.service_equipment` is now a dictionary to provide clear access to the servicing equipment details.

### Methodology Updates

Expand Down
79 changes: 77 additions & 2 deletions wombat/core/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,79 @@
)
return log_df

def _calculate_windfarm_total(
self, op: pd.DataFrame, prod: pd.DataFrame | None = None
) -> pd.DataFrame:
"""Calculates the overall wind farm operational level, accounting for substation
downtime by multiplying the sum of all downstream turbine operational levels by
the substation's operational level.

Parameters
----------
op : pd.DataFrame
The turbine and substation operational level DataFrame.

Notes
-----
This is a crude cap on the operations, and so a smarter way of capping
the availability should be added in the future.

Returns
-------
pd.DataFrame
The aggregate wind farm operational level.
"""
t_id = self.windfarm.turbine_id
turbines = self.windfarm.turbine_weights[t_id].values * op[t_id]

Check warning on line 777 in wombat/core/environment.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/environment.py#L776-L777

Added lines #L776 - L777 were not covered by tests
total = np.sum(
[
op[[sub]]
* np.array(
[
[math.fsum(row)]
for _, row in turbines[val["turbines"]].iterrows()
]
).reshape(-1, 1)
for sub, val in self.windfarm.substation_turbine_map.items()
],
axis=0,
)
return total

Check warning on line 791 in wombat/core/environment.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/environment.py#L791

Added line #L791 was not covered by tests

def _calculate_adjusted_production(
self, op: pd.DataFrame, prod: pd.DataFrame
) -> pd.DataFrame:
"""Calculates the overall wind farm power production and adjusts individual
turbine production by accounting for substation downtime. This is done by
multiplying the all downstream turbine operational levels by the substation's
operational level.

Parameters
----------
op : pd.DataFrame
The operational level DataFrame with turbine, substation, and windfarm
columns.
prod : pd.DataFrame
The turbine energy production DataFrame.

Notes
-----
This is a crude cap on the operations, and so a smarter way of capping
the availability should be added in the future.

Returns
-------
pd.DataFrame
Either the aggregate wind farm operational level or the total wind farm
energy production if the :py:attr:`prod` is provided.
"""
# Adjust individual turbine production for substation downtime
prod = prod.copy()

Check warning on line 821 in wombat/core/environment.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/environment.py#L821

Added line #L821 was not covered by tests
for sub, val in self.windfarm.substation_turbine_map.items():
prod[val["turbines"]] *= op[[sub]].values
prod.windfarm = prod[self.windfarm.turbine_id].sum(axis=1)
return prod[["windfarm"]]

Check warning on line 825 in wombat/core/environment.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/environment.py#L823-L825

Added lines #L823 - L825 were not covered by tests

def load_operations_log_dataframe(self) -> pd.DataFrame:
"""Imports the logging file created in ``run`` and returns it as a formatted
``pandas.DataFrame``.
Expand All @@ -769,7 +842,7 @@
.set_index("datetime")
.sort_values("datetime")
)

log_df["windfarm"] = self._calculate_windfarm_total(log_df)

Check warning on line 845 in wombat/core/environment.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/environment.py#L845

Added line #L845 was not covered by tests
return log_df

def power_production_potential_to_csv( # type: ignore
Expand Down Expand Up @@ -829,7 +902,9 @@
# the max of the substation's operating capacity and then summed.
production_df = potential_df.copy()
production_df[turbines] *= operations[turbines].values
production_df.windfarm = production_df[turbines].sum(axis=1)
production_df.windfarm = self._calculate_adjusted_production(

Check warning on line 905 in wombat/core/environment.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/environment.py#L905

Added line #L905 was not covered by tests
operations, production_df
)
pa.csv.write_csv(
pa.Table.from_pandas(production_df),
self.power_production_fname,
Expand Down
87 changes: 38 additions & 49 deletions wombat/core/post_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

import warnings
from copy import deepcopy
from math import fsum
from typing import TYPE_CHECKING
from pathlib import Path
from itertools import chain, product
Expand Down Expand Up @@ -52,18 +51,23 @@


def _calculate_time_availability(
availability: np.ndarray, by_turbine: bool = False
availability: pd.DataFrame,
by_turbine: bool = False,
turbine_id: list[str] | None = None,
) -> float | np.ndarray:
"""Calculates the availability ratio of the whole timeseries or the whole
timeseries, by turbine.

Parameters
----------
availability : np.ndarray
availability : pd.DataFrame
Timeseries array of operating ratios.
by_turbine : bool, optional
If True, calculates the availability rate of each column, otherwise across the
whole array, by default False.
turbine_id : list[str], optional
A list of turbine IDs that is required if :py:attr:`by_turbine` is ``True``, by
default None.

Returns
-------
Expand All @@ -73,8 +77,8 @@
"""
availability = availability > 0
if by_turbine:
return availability.sum(axis=0) / availability.shape[0]
return availability.sum() / availability.size
return availability[turbine_id].values.sum(axis=0) / availability.shape[0]
return availability.windfarm.values.sum() / availability.windfarm.size

Check warning on line 81 in wombat/core/post_processor.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/post_processor.py#L80-L81

Added lines #L80 - L81 were not covered by tests


class Metrics:
Expand Down Expand Up @@ -196,19 +200,19 @@

if isinstance(events, str):
events = self._read_data(events)
self.events = self._apply_inflation_rate(self._tidy_data(events, kind="events"))
self.events = self._apply_inflation_rate(self._tidy_data(events))

Check warning on line 203 in wombat/core/post_processor.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/post_processor.py#L203

Added line #L203 was not covered by tests

if isinstance(operations, str):
operations = self._read_data(operations)
self.operations = self._tidy_data(operations, kind="operations")
self.operations = self._tidy_data(operations)

Check warning on line 207 in wombat/core/post_processor.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/post_processor.py#L207

Added line #L207 was not covered by tests

if isinstance(potential, str):
potential = self._read_data(potential)
self.potential = self._tidy_data(potential, kind="potential")
self.potential = self._tidy_data(potential)

Check warning on line 211 in wombat/core/post_processor.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/post_processor.py#L211

Added line #L211 was not covered by tests

if isinstance(production, str):
production = self._read_data(production)
self.production = self._tidy_data(production, kind="production")
self.production = self._tidy_data(production)

Check warning on line 215 in wombat/core/post_processor.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/post_processor.py#L215

Added line #L215 was not covered by tests

@classmethod
def from_simulation_outputs(cls, fpath: Path | str, fname: str) -> Metrics:
Expand All @@ -232,20 +236,14 @@
metrics = cls(**data)
return metrics

def _tidy_data(self, data: pd.DataFrame, kind: str) -> pd.DataFrame:
def _tidy_data(self, data: pd.DataFrame) -> pd.DataFrame:
"""Tidies the "raw" csv-converted data to be able to be used among the
``Metrics`` class.

Parameters
----------
data : pd.DataFrame
The csv log data.
kind : str
The category of the input provided to ``data``. Should be one of:
- "operations"
- "events"
- "potential"
- "production"

Returns
-------
Expand All @@ -270,23 +268,6 @@
month=data.env_datetime.dt.month,
day=data.env_datetime.dt.day,
)
if kind == "operations":
data[self.turbine_id] = data[self.turbine_id].astype(float)
turbines = (
self.turbine_weights[self.turbine_id].values * data[self.turbine_id]
)
windfarm = np.sum(
[
data[[sub]]
* np.array(
[[fsum(row)] for _, row in turbines[val["turbines"]].iterrows()]
).reshape(-1, 1)
for sub, val in self.substation_turbine_map.items()
],
axis=0,
)
windfarm = pd.DataFrame(windfarm, columns=["windfarm"], index=data.index)
data = pd.concat([data, windfarm], axis=1)
return data

def _read_data(self, fname: str) -> pd.DataFrame:
Expand Down Expand Up @@ -394,13 +375,15 @@
for sub, val in self.substation_turbine_map.items():
turbine_operations[val["turbines"]] *= self.operations[[sub]].values

hourly = turbine_operations.loc[:, self.turbine_id].values
hourly = turbine_operations.loc[:, ["windfarm"] + self.turbine_id]

Check warning on line 378 in wombat/core/post_processor.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/post_processor.py#L378

Added line #L378 was not covered by tests

# TODO: The below should be better summarized as:
# (availability > 0).groupby().sum() / groupby().count()

if frequency == "project":
availability = _calculate_time_availability(hourly, by_turbine=by_turbine)
availability = _calculate_time_availability(

Check warning on line 384 in wombat/core/post_processor.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/post_processor.py#L384

Added line #L384 was not covered by tests
hourly, by_turbine=by_turbine, turbine_id=self.turbine_id
)
if not by_turbine:
return pd.DataFrame([availability], columns=["windfarm"])

Expand All @@ -416,7 +399,9 @@
counts = counts[self.turbine_id] if by_turbine else counts[["windfarm"]]
annual = [
_calculate_time_availability(
hourly[date_time.year == year], by_turbine=by_turbine
hourly[date_time.year == year],
by_turbine=by_turbine,
turbine_id=self.turbine_id,
)
for year in counts.index
]
Expand All @@ -427,7 +412,9 @@
counts = counts[self.turbine_id] if by_turbine else counts[["windfarm"]]
monthly = [
_calculate_time_availability(
hourly[date_time.month == month], by_turbine=by_turbine
hourly[date_time.month == month],
by_turbine=by_turbine,
turbine_id=self.turbine_id,
)
for month in counts.index
]
Expand All @@ -440,6 +427,7 @@
_calculate_time_availability(
hourly[(date_time.year == year) & (date_time.month == month)],
by_turbine=by_turbine,
turbine_id=self.turbine_id,
)
for year, month in counts.index
]
Expand Down Expand Up @@ -472,31 +460,32 @@
raise ValueError('``by`` must be one of "windfarm" or "turbine".')
by_turbine = by == "turbine"

production = self.production.loc[:, self.turbine_id]
potential = self.potential.loc[:, self.turbine_id]
if by_turbine:
production = self.production.loc[:, self.turbine_id]
potential = self.potential.loc[:, self.turbine_id]

Check warning on line 465 in wombat/core/post_processor.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/post_processor.py#L464-L465

Added lines #L464 - L465 were not covered by tests
else:
production = self.production[["windfarm"]].copy()
potential = self.potential[["windfarm"]].copy()

Check warning on line 468 in wombat/core/post_processor.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/post_processor.py#L467-L468

Added lines #L467 - L468 were not covered by tests

if frequency == "project":
production = production.values
potential = potential.values
if (potential == 0).sum() > 0:
potential[potential == 0] = 1
if not by_turbine:
return pd.DataFrame(
[production.sum() / potential.sum()], columns=["windfarm"]
)
availability = pd.DataFrame(
(production.sum(axis=0) / potential.sum(axis=0)).reshape(1, -1),
columns=self.turbine_id,
)
return availability

availability = production.sum(axis=0) / potential.sum(axis=0)

Check warning on line 476 in wombat/core/post_processor.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/post_processor.py#L476

Added line #L476 was not covered by tests
if by_turbine:
return pd.DataFrame([availability], columns=self.turbine_id)

Check warning on line 478 in wombat/core/post_processor.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/post_processor.py#L478

Added line #L478 was not covered by tests
else:
return pd.DataFrame([availability], columns=["windfarm"])

Check warning on line 480 in wombat/core/post_processor.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/post_processor.py#L480

Added line #L480 was not covered by tests

production["year"] = production.index.year.values
production["month"] = production.index.month.values

potential["year"] = potential.index.year.values
potential["month"] = potential.index.month.values

group_cols = deepcopy(self.turbine_id)
group_cols = deepcopy(self.turbine_id) if by_turbine else ["windfarm"]

Check warning on line 488 in wombat/core/post_processor.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/post_processor.py#L488

Added line #L488 was not covered by tests
if frequency == "annual":
group_cols.insert(0, "year")
production = production[group_cols].groupby("year").sum()
Expand Down
25 changes: 18 additions & 7 deletions wombat/core/simulation_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,20 +292,33 @@
self.windfarm = Windfarm(self.env, self.config.layout, self.repair_manager)

# Create the servicing equipment and set the necessary environment variables
self.service_equipment = []
self.service_equipment: dict[str, ServiceEquipment] = {} # type: ignore

Check warning on line 295 in wombat/core/simulation_api.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/simulation_api.py#L295

Added line #L295 was not covered by tests
for service_equipment in self.config.service_equipment:
equipment = ServiceEquipment(
self.env, self.windfarm, self.repair_manager, service_equipment
)
equipment.finish_setup_with_environment_variables()
self.service_equipment.append(equipment)
name = equipment.settings.name

Check warning on line 301 in wombat/core/simulation_api.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/simulation_api.py#L301

Added line #L301 was not covered by tests
if name in self.service_equipment:
raise ValueError(

Check warning on line 303 in wombat/core/simulation_api.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/simulation_api.py#L303

Added line #L303 was not covered by tests
f"Servicing equipment `{name}` already exists, please use unique"
" names for all servicing equipment."
)
self.service_equipment[name] = equipment # type: ignore

Check warning on line 307 in wombat/core/simulation_api.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/simulation_api.py#L307

Added line #L307 was not covered by tests

# Create the port and add any tugboats to the available servicing equipment list
if self.config.port is not None:
self.port = Port(
self.env, self.windfarm, self.repair_manager, self.config.port
)
self.service_equipment.extend(self.port.service_equipment_manager.items)
for service_equipment in self.port.service_equipment_manager.items:
name = service_equipment.settings.name # type: ignore

Check warning on line 315 in wombat/core/simulation_api.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/simulation_api.py#L315

Added line #L315 was not covered by tests
if name in self.service_equipment:
raise ValueError(

Check warning on line 317 in wombat/core/simulation_api.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/simulation_api.py#L317

Added line #L317 was not covered by tests
f"Servicing equipment `{name}` already exists, please use"
" unique names for all servicing equipment."
)
self.service_equipment[name] = service_equipment # type: ignore

Check warning on line 321 in wombat/core/simulation_api.py

View check run for this annotation

Codecov / codecov/patch

wombat/core/simulation_api.py#L321

Added line #L321 was not covered by tests

if self.config.project_capacity * 1000 != round(self.windfarm.capacity, 6):
raise ValueError(
Expand Down Expand Up @@ -370,7 +383,7 @@
substation_id=self.windfarm.substation_id.tolist(),
turbine_id=self.windfarm.turbine_id.tolist(),
substation_turbine_map=substation_turbine_map,
service_equipment_names=[el.settings.name for el in self.service_equipment],
service_equipment_names=[*self.service_equipment], # type: ignore
)

def save_metrics_inputs(self) -> None:
Expand All @@ -396,9 +409,7 @@
"substation_id": self.windfarm.substation_id.tolist(),
"turbine_id": self.windfarm.turbine_id.tolist(),
"substation_turbine_map": substation_turbine_map,
"service_equipment_names": [
el.settings.name for el in self.service_equipment
],
"service_equipment_names": [*self.service_equipment],
}

with open(self.env.metrics_input_fname, "w") as f:
Expand Down
Loading