diff --git a/backend_py/primary/primary/services/inplace_volumetrics_assembler/inplace_volumetrics_assembler.py b/backend_py/primary/primary/services/inplace_volumetrics_assembler/inplace_volumetrics_assembler.py deleted file mode 100644 index 498aaf077..000000000 --- a/backend_py/primary/primary/services/inplace_volumetrics_assembler/inplace_volumetrics_assembler.py +++ /dev/null @@ -1,152 +0,0 @@ -from enum import StrEnum -from dataclasses import dataclass -from typing import Dict, List, Sequence, Union - -import pyarrow as pa -import pyarrow.compute as pc - -from primary.services.sumo_access.inplace_volumetrics_acces_NEW import InplaceVolumetricsAccess - - -class InplaceVolumetricsIndexNames(StrEnum): - """ - Definition of valid index names for an inplace volumetrics table - """ - - ZONE = "ZONE" - REGION = "REGION" - FACIES = "FACIES" - LICENSE = "LICENSE" - - -@dataclass -class InplaceVolumetricsIndex: - """ - Unique values for an index column in an inplace volumetrics table - - NOTE: Ideally all values should be strings, but it is possible that some values are integers - especially for REGION - """ - - index_name: InplaceVolumetricsIndexNames - values: List[Union[str, int]] # List of values: str or int - - -@dataclass -class InplaceVolumetricsTableDefinition: - """Definition of a volumetric table""" - - name: str - indexes: List[InplaceVolumetricsIndex] - result_names: List[str] - - -class AggregateByEach(StrEnum): - # FLUID_ZONE = "FLUID_ZONE" - ZONE = "ZONE" - REGION = "REGION" - FACIES = "FACIES" - # LICENSE = "LICENSE" - REAL = "REAL" - - -class FluidZone(StrEnum): - OIL = "Oil" - GAS = "Gas" - Water = "Water" # TODO: Remove or keep? - - -class InplaceVolumetricsAssembler: - def __init__(self, inplace_volumetrics_access: InplaceVolumetricsAccess): - self._inplace_volumetrics_access = inplace_volumetrics_access - - @staticmethod - def get_volumetric_columns_from_response_names_and_fluid_zones( - response_names: set[str], fluid_zones: List[FluidZone] - ) -> list[str]: - """ - Function to get volumetric columns from response names and fluid zones - """ - - volumetric_columns = [] - - for fluid_zone in fluid_zones: - for response_name in response_names: - volumetric_columns.append(f"{response_name}_{fluid_zone.value}") - - return volumetric_columns - - async def get_aggregated_volumetric_table_data_async( - self, - table_name: str, - response_names: set[str], - fluid_zones: List[FluidZone], - realizations: Sequence[int] = None, - index_filter: List[InplaceVolumetricsIndex] = None, - aggregate_by_each_list: Sequence[AggregateByEach] = None, - ) -> Dict[str, List[str | int | float]]: - - if realizations is not None and len(realizations) == 0: - return {} - - # NOTE: How to ensure that all volumetric columns are present in the table? - # - Send Dict: {response_name: [fluid_zone]} from front-end based on metadata? - volumetric_columns: list[str] = self.get_volumetric_columns_from_response_names_and_fluid_zones( - response_names, fluid_zones - ) - - # Get the inplace volumetrics table from collection in Sumo - inplace_volumetrics_table: pa.Table = self._inplace_volumetrics_access.get_inplace_volumetrics_table_async( - table_name=table_name, column_names=volumetric_columns - ) - - # Build mask for rows - default all rows - mask = pa.array([True] * inplace_volumetrics_table.num_rows) - - # Add mask for each index filter - for index in index_filter: - index_column_name = index.index_name.value - index_mask = pc.is_in(inplace_volumetrics_table[index_column_name], value_set=pa.array(index.values)) - mask = pc.and_(mask, index_mask) - - # Add mask for realizations - if realizations is not None: - - realization_mask = pc.is_in(inplace_volumetrics_table["REAL"], value_set=pa.array(realizations)) - mask = pc.and_(mask, realization_mask) - - filtered_table = inplace_volumetrics_table.filter(mask) - - if len(aggregate_by_each_list) == 0: - return filtered_table.to_pydict() - - # Group by each of the index columns (always aggregate by realization) - aggregate_by_each = set([col.value for col in aggregate_by_each_list]) - - columns_to_group_by_for_sum = aggregate_by_each.copy() - if "REAL" not in columns_to_group_by_for_sum: - columns_to_group_by_for_sum.add("REAL") - - # Aggregate sum for each response name after grouping - aggregated_vol_table = filtered_table.group_by(columns_to_group_by_for_sum).aggregate( - [(response_name, "sum") for response_name in response_names] - ) - suffix_to_remove = "_sum" - - # ********************* AGGREGATE BY REALIZATION ********************* - - # If aggregate_by_each does not contain "REAL", then aggregate mean across realizations - if "REAL" not in aggregate_by_each: - aggregated_vol_table = aggregated_vol_table.group_by(aggregate_by_each).aggregate( - [(f"{response_name}_sum", "mean") for response_name in response_names] - ) - suffix_to_remove = "_sum_mean" - - # Remove suffix from column names - column_names = aggregated_vol_table.column_names - new_column_names = [column_name.replace(suffix_to_remove, "") for column_name in column_names] - aggregated_vol_table = aggregated_vol_table.rename_columns(new_column_names) - - # Convert to dict with column name as key, and column array as value - aggregated_vol_table_dict = aggregated_vol_table.to_pydict() - - return aggregated_vol_table_dict diff --git a/backend_py/primary/primary/services/inplace_volumetrics_provider/_conversion/_conversion.py b/backend_py/primary/primary/services/inplace_volumetrics_provider/_conversion/_conversion.py new file mode 100644 index 000000000..3cabc13ae --- /dev/null +++ b/backend_py/primary/primary/services/inplace_volumetrics_provider/_conversion/_conversion.py @@ -0,0 +1,152 @@ +from typing import List + +import re + +from primary.services.sumo_access.inplace_volumetrics_types import ( + FluidZone, + Property, +) + +""" +This file contains helper functions for conversion between different data types used in the Inplace Volumetrics provider + +The table data from Sumo retrieves raw_volumetric_columns with suffixes for fluid zones, e.g. "STOIIP_OIL", "STOIIP_GAS", "STOIIP_WATER" + +Conversion is made back and forth: + +- Raw volumetric columns converted into volume names, without suffixes and a list of available fluid zones. +Based on list of volume names, the available properties are determined. The list of volume names and properties equals the responses. + +- A list of responses is converted back to list of volume names and properties. The needed volume names to calculated a property is found, +and a complete list of volume names can be combined with list of fluid zones to get a list of raw volumetric columns. + +Terms: +- Front-end: responses = volume_names + properties (w/o suffixes) +- Back-end: volumetric_column_names = create_list_of_volume_names(responses) + fluid_zones (with suffixes) + +""" + +def get_properties_in_response_names(response_names: List[str]) -> List[str]: + """ + Function to get properties from response names + """ + + properties = set() + for response_name in response_names: + if response_name in Property.__members__: + properties.add(response_name) + + return list(properties) + +def get_required_volume_names_from_properties(properties: List[str]) -> List[str]: + """ + Function to convert properties to list of required volume names + """ + + volume_names = set() + for property in properties: + volume_names.update(get_required_volume_names_from_property(property)) + + return list(volume_names) + +def get_required_volume_names_from_property(property: str) -> List[str]: + """ + Function to convert property to list of required volume names + """ + + if property == "NTG": + return ["BULK", "NET"] + if property == "PORO": + return ["BULK", "PORV"] + if property == "PORO_NET": + return ["PORV", "NET"] + if property == "SW": + return ["HCPV", "PORV"] + if property == "BO": + return ["HCPV", "STOIIP"] + if property == "BG": + return ["HCPV", "GIIP"] + else: + raise ValueError(f"Unhandled property: {property}") + + +def get_available_properties_from_volume_names(volume_names: set[str]) -> List[str]: + """ + Function to get available properties from volume names + """ + + properties = set() + if set(["BULK", "NET"]).issubset(volume_names): + properties.add(Property.NTG.value) + if set(["PORV", "BULK"]).issubset(volume_names): + properties.add(Property.PORO.value) + if set(["PORV", "NET"]).issubset(volume_names): + properties.add(Property.PORO_NET.value) + if set(["HCPV", "PORV"]).issubset(volume_names): + properties.add(Property.SW.value) + if set(["HCPV", "STOIIP"]).issubset(volume_names): + properties.add(Property.BO.value) + if set(["HCPV", "GIIP"]).issubset(volume_names): + properties.add(Property.BG.value) + + return list(properties) + + +def get_volume_names_from_raw_volumetric_column_names(raw_volumetric_column_names: set[str]) -> List[str]: + """ + Function to get volume names from volumetric column names + + Raw volumetric columns have suffixes for fluid zones, e.g. "STOIIP_OIL", "STOIIP_GAS", "STOIIP_WATER" + """ + + volume_names = set() + + # Clean volume names for suffixes + for column_name in raw_volumetric_column_names: + cleaned_name = re.sub(r"_(OIL|GAS|WATER)", "", column_name) + volume_names.add(cleaned_name) + + # Add total HC responses + if set(["STOIIP", "ASSOCIATEDOIL"]).issubset(volume_names): + volume_names.add("STOIIP_TOTAL") + if set(["GIIP", "ASSOCIATEDGAS"]).issubset(volume_names): + volume_names.add("GIIP_TOTAL") + + return list(volume_names) + + +def get_fluid_zones(raw_volumetric_column_names: set[str]) -> List[FluidZone]: + """ + Function to get fluid zones from raw volumetric column names + """ + full_set = {FluidZone.OIL, FluidZone.GAS, FluidZone.Water} + fluid_zones: set[FluidZone] = set() + for column_name in raw_volumetric_column_names: + if "_OIL" in column_name: + fluid_zones.add(FluidZone.OIL) + elif "_GAS" in column_name: + fluid_zones.add(FluidZone.GAS) + elif "_WATER" in column_name: + fluid_zones.add(FluidZone.Water) + + if fluid_zones == full_set: + break + + return list(fluid_zones) + + +def create_raw_volumetric_columns_from_volume_names_and_fluid_zones( + volume_names: set[str], fluid_zones: List[FluidZone] +) -> list[str]: + """ + Function to create raw volumetric columns from volume names and fluid zones + """ + + volumetric_columns = [] + + for fluid_zone in fluid_zones: + for volume_name in volume_names: + + volumetric_columns.append(f"{volume_name}_{fluid_zone.value}") + + return volumetric_columns diff --git a/backend_py/primary/primary/services/inplace_volumetrics_provider/inplace_volumetrics_provider.py b/backend_py/primary/primary/services/inplace_volumetrics_provider/inplace_volumetrics_provider.py new file mode 100644 index 000000000..154e75a97 --- /dev/null +++ b/backend_py/primary/primary/services/inplace_volumetrics_provider/inplace_volumetrics_provider.py @@ -0,0 +1,186 @@ +from typing import Any, Dict, List, Sequence + +import pyarrow as pa +import pyarrow.compute as pc + +from primary.services.sumo_access.inplace_volumetrics_acces_NEW import InplaceVolumetricsAccess +from primary.services.sumo_access.inplace_volumetrics_types import ( + AggregateByEach, + FluidZone, + InplaceVolumetricsIndex, +) + +from ._conversion._conversion import ( + get_available_properties_from_volume_names, + get_fluid_zones, + get_properties_in_response_names, + get_required_volume_names_from_properties, + get_volume_names_from_raw_volumetric_column_names, + create_raw_volumetric_columns_from_volume_names_and_fluid_zones, +) + + +# - InplaceVolumetricsConstructor +# - InplaceVolumetricsFabricator +# - InplaceVolumetricsDataManufacturer +class InplaceVolumetricsProvider: + """ + This class provides an interface for interacting with definitions used in front-end for assembling and providing + metadata and inplace volumetrics table data + + The class interacts with the InplaceVolumetricsAccess class to retrieve data from Sumo and assemble it into a format + that can be used in the front-end. It also performs validation of the data and aggregation methods where needed. + + The provider contains conversion from response names, properties and fluid zones into volumetric column names that can + be used to fetch data from Sumo. + + Front-end: responses = volume_columns + properties + + Sumo: volumetric_column_names = responses + fluid_zones + + + """ + + def __init__(self, inplace_volumetrics_access: InplaceVolumetricsAccess): + self._inplace_volumetrics_access = inplace_volumetrics_access + + # TODO: When having metadata, provide all column names, and the get the possible properties from the response names + # Provide the available properties from metadata, without suffix and provide possible FluidZones + async def get_volumetric_table_metadata(self) -> Any: + raw_volumetric_column_names = await self._inplace_volumetrics_access.get_volumetric_column_names_async() + + fluid_zones = get_fluid_zones(raw_volumetric_column_names) + volume_names = get_volume_names_from_raw_volumetric_column_names(raw_volumetric_column_names) + available_property_names = get_available_properties_from_volume_names(volume_names) + responses = list(set([volume_names+available_property_names])) + + # TODO: Consider + # responses_info: Dict[str, List[FluidZone]] = {} + # I.e.: responses_info["STOIIP"] = [FluidZone.OIL], etc. + + return { + "name": "INSERT TABLE NAME", + "fluid_zones": fluid_zones, + "responses": responses, + } + + + async def get_aggregated_volumetric_table_data_async( + self, + table_name: str, + response_names: set[str], + fluid_zones: List[FluidZone], + realizations: Sequence[int] = None, + index_filter: List[InplaceVolumetricsIndex] = None, + aggregate_by_each_list: Sequence[AggregateByEach] = None, + ) -> Dict[str, List[str | int | float]]: + + # Detect properties and find volume names needed to calculate properties + properties = get_properties_in_response_names(response_names) + required_volume_names_for_properties = get_required_volume_names_from_properties(properties) + + # Extract volume names among response names + volume_names = list(set(response_names) - set(properties)) + + # Find all volume names needed from Sumo + all_volume_names = set(volume_names + required_volume_names_for_properties) + + # Create the raw volumetric columns from all volume names and fluid zones + raw_volumetric_column_names = create_raw_volumetric_columns_from_volume_names_and_fluid_zones( + all_volume_names, fluid_zones + ) + + # Get the volumetric table + filtered_table = await self._create_filtered_volumetric_table_data_async( + table_name=table_name, + volumetric_columns=raw_volumetric_column_names, + realizations=realizations, + index_filter=index_filter, + ) + + # TODO: Transform filtered table to table with response names as columns and an additional FLUID_ZONE column before aggregation? + # Remove suffixes from column names of table? + + if len(aggregate_by_each_list) == 0: + return filtered_table.to_pydict() + + # Group by each of the index columns (always aggregate by realization) + aggregate_by_each = set([col.value for col in aggregate_by_each_list]) + + columns_to_group_by_for_sum = aggregate_by_each.copy() + if "REAL" not in columns_to_group_by_for_sum: + columns_to_group_by_for_sum.add("REAL") + + # Aggregate sum for each response name after grouping + aggregated_vol_table = filtered_table.group_by(columns_to_group_by_for_sum).aggregate( + [(response_name, "sum") for response_name in response_names] + ) + suffix_to_remove = "_sum" + + # ********************* AGGREGATE BY REALIZATION ********************* + + # If aggregate_by_each does not contain "REAL", then aggregate mean across realizations + if "REAL" not in aggregate_by_each: + aggregated_vol_table = aggregated_vol_table.group_by(aggregate_by_each).aggregate( + [(f"{response_name}_sum", "mean") for response_name in response_names] + ) + suffix_to_remove = "_sum_mean" + + # Remove suffix from column names + column_names = aggregated_vol_table.column_names + new_column_names = [column_name.replace(suffix_to_remove, "") for column_name in column_names] + aggregated_vol_table = aggregated_vol_table.rename_columns(new_column_names) + + # Convert to dict with column name as key, and column array as value + aggregated_vol_table_dict = aggregated_vol_table.to_pydict() + + return aggregated_vol_table_dict + + + async def _create_filtered_volumetric_table_data_async( + self, + table_name: str, + volumetric_columns: set[str], + realizations: Sequence[int] = None, + index_filter: List[InplaceVolumetricsIndex] = None, + ) -> pa.Table: + """ + Create table filtered on index values and realizations + """ + if realizations is not None and len(realizations) == 0: + return {} + + # Get the inplace volumetrics table from collection in Sumo + # TODO: + # Soft vs hard fail depends on detail level when building the volumetric columns from retrieved response names + fluid zones + # Soft fail: get_inplace_volumetrics_table_no_throw_async() does not require matching volumetric column names + # Hard fail: get_inplace_volumetrics_table_async() throws an exception if requested column names are not found + inplace_volumetrics_table: pa.Table = self._inplace_volumetrics_access.get_inplace_volumetrics_table_async( + table_name=table_name, column_names=volumetric_columns + ) + + # Build mask for rows - default all rows + mask = pa.array([True] * inplace_volumetrics_table.num_rows) + + # Add mask for realizations + if realizations is not None: + # Check if every element in pa.array(realizations) exists in vol_table["REAL"] + real_values_set = set(inplace_volumetrics_table["REAL"].to_pylist()) + missing_realizations = [real for real in realizations if real not in real_values_set] + + if missing_realizations: + raise ValueError( + f"Missing data error: The following realization values do not exist in 'REAL' column: {missing_realizations}" + ) + + realization_mask = pc.is_in(inplace_volumetrics_table["REAL"], value_set=pa.array(realizations)) + mask = pc.and_(mask, realization_mask) + + # Add mask for each index filter + for index in index_filter: + index_column_name = index.index_name.value + index_mask = pc.is_in(inplace_volumetrics_table[index_column_name], value_set=pa.array(index.values)) + mask = pc.and_(mask, index_mask) + + filtered_table = inplace_volumetrics_table.filter(mask) + return filtered_table \ No newline at end of file diff --git a/backend_py/primary/primary/services/sumo_access/inplace_volumetrics_acces_NEW.py b/backend_py/primary/primary/services/sumo_access/inplace_volumetrics_acces_NEW.py index dd390ea56..612274792 100644 --- a/backend_py/primary/primary/services/sumo_access/inplace_volumetrics_acces_NEW.py +++ b/backend_py/primary/primary/services/sumo_access/inplace_volumetrics_acces_NEW.py @@ -39,6 +39,39 @@ async def from_case_uuid_async( case: Case = await create_sumo_case_async(client=sumo_client, case_uuid=case_uuid, want_keepalive_pit=False) return InplaceVolumetricsAccess(case=case, case_uuid=case_uuid, iteration_name=iteration_name) + async def get_inplace_volumetrics_table_no_throw_async( + self, table_name: str, column_names: Optional[set[str]] = None + ) -> Optional[pa.Table]: + """ + Get inplace volumetrics data for list of columns for given case and iteration as a pyarrow table. + + The volumes are fetched from collection in Sumo and put together in a single table, i.e. a column per response. + + Note: This method does not throw an exception if requested column names are not found. + + Returns: + pa.Table with columns: ZONE, REGION, FACIES, REAL, and the available requested column names. + """ + + # Get collection of tables per requested column + requested_columns = column_names if column_names is None else list(column_names) + vol_table_collection = self._case.tables.filter( + aggregation="collection", + name=table_name, + tagname=["vol", "volumes", "inplace"], + iteration=self._iteration_name, + column=requested_columns, + ) + + # Assemble tables into a single table + vol_table: pa.Table = await self._assemble_volumetrics_table_collection_into_single_table_async( + vol_table_collection=vol_table_collection, + table_name=table_name, + column_names=column_names, + ) + + return vol_table + async def get_inplace_volumetrics_table_async( self, table_name: str, column_names: Optional[set[str]] = None ) -> pa.Table: @@ -46,14 +79,12 @@ async def get_inplace_volumetrics_table_async( Get inplace volumetrics data for list of columns for given case and iteration as a pyarrow table. The volumes are fetched from collection in Sumo and put together in a single table, i.e. a column per response. - """ - timer = PerfTimer() - # NOTE: "REAL" is not an index in metadata, but is an expected column in the table - expected_table_index_columns = set(["ZONE", "REGION", "FACIES", "REAL"]) - all_expected_columns = expected_table_index_columns + column_names + Returns: + pa.Table with columns: ZONE, REGION, FACIES, REAL, and the requested column names. + """ - # Get collection of tables per response + # Get collection of tables per requested column requested_columns = column_names if column_names is None else list(column_names) vol_table_collection = self._case.tables.filter( aggregation="collection", @@ -63,12 +94,10 @@ async def get_inplace_volumetrics_table_async( column=requested_columns, ) - num_tables_in_collection = await vol_table_collection.length_async() - if num_tables_in_collection == 0: - raise NoDataError( - f"No inplace volumetrics tables found in case={self._case_uuid}, iteration={self._iteration_name}, table_name={table_name}, column_names={column_names}", - Service.SUMO, - ) + # Expected columns + # NOTE: "REAL" is not an index in metadata, but is an expected column in the tables from collection + expected_table_index_columns = set(["ZONE", "REGION", "FACIES", "REAL"]) + all_expected_columns = expected_table_index_columns + column_names # Find column names not among collection columns collection_columns = await vol_table_collection.columns_async @@ -79,6 +108,45 @@ async def get_inplace_volumetrics_table_async( Service.SUMO, ) + # Assemble tables into a single table + vol_table: pa.Table = await self._assemble_volumetrics_table_collection_into_single_table_async( + vol_table_collection=vol_table_collection, + table_name=table_name, + column_names=column_names, + ) + + # Validate the table columns + if set(vol_table.column_names) != all_expected_columns: + missing_columns = all_expected_columns - set(vol_table.column_names) + raise InvalidDataError( + f"Missing columns: {missing_columns}, in the volumetric table {self._case_uuid}, {table_name}", + Service.SUMO, + ) + + return vol_table + + async def _assemble_volumetrics_table_collection_into_single_table_async( + self, + vol_table_collection: TableCollection, + table_name: str, + column_names: set[str], + ) -> pa.Table: + """ + Retrieve the inplace volumetrics tables from Sumo and assemble them into a single table. + + Index columns: ZONE, REGION, FACIES, REAL + Response columns: column_names + + """ + expected_table_index_columns = set(["ZONE", "REGION", "FACIES", "REAL"]) + + num_tables_in_collection = await vol_table_collection.length_async() + if num_tables_in_collection == 0: + raise NoDataError( + f"No inplace volumetrics tables found in case={self._case_uuid}, iteration={self._iteration_name}, table_name={table_name}, column_names={column_names}", + Service.SUMO, + ) + # Download tables in parallel tasks = [asyncio.create_task(table.to_arrow_async()) for table in vol_table_collection] arrow_tables: list[pa.Table] = await asyncio.gather(*tasks) @@ -115,12 +183,4 @@ async def get_inplace_volumetrics_table_async( response_column = response_table[response_name] vol_table = vol_table.append_column(response_name, response_column) - # Validate the table columns - if set(vol_table.column_names) != all_expected_columns: - missing_columns = all_expected_columns - set(vol_table.column_names) - raise InvalidDataError( - f"Missing columns: {missing_columns}, in the volumetric table {self._case_uuid}, {table_name}", - Service.SUMO, - ) - return vol_table diff --git a/backend_py/primary/primary/services/sumo_access/inplace_volumetrics_types.py b/backend_py/primary/primary/services/sumo_access/inplace_volumetrics_types.py new file mode 100644 index 000000000..0bc5a628d --- /dev/null +++ b/backend_py/primary/primary/services/sumo_access/inplace_volumetrics_types.py @@ -0,0 +1,59 @@ +from enum import StrEnum +from dataclasses import dataclass +from typing import List, Union + + +class InplaceVolumetricsIndexNames(StrEnum): + """ + Definition of valid index names for an inplace volumetrics table + """ + + ZONE = "ZONE" + REGION = "REGION" + FACIES = "FACIES" + LICENSE = "LICENSE" + +class AggregateByEach(StrEnum): + # FLUID_ZONE = "FLUID_ZONE" + ZONE = "ZONE" + REGION = "REGION" + FACIES = "FACIES" + # LICENSE = "LICENSE" + REAL = "REAL" + + +class FluidZone(StrEnum): + OIL = "Oil" + GAS = "Gas" + Water = "Water" # TODO: Remove or keep? + + +class Property(StrEnum): + NTG = "NTG" + PORO = "PORO" + PORO_NET = "PORO_NET" + SW = "SW" + BO = "BO" + BG = "BG" + + +@dataclass +class InplaceVolumetricsIndex: + """ + Unique values for an index column in an inplace volumetrics table + + NOTE: Ideally all values should be strings, but it is possible that some values are integers - especially for REGION + """ + + index_name: InplaceVolumetricsIndexNames + values: List[Union[str, int]] # List of values: str or int + + +@dataclass +class InplaceVolumetricsTableDefinition: + """Definition of a volumetric table""" + + name: str + indexes: List[InplaceVolumetricsIndex] + result_names: List[str] + # fluid_zones: List[FluidZone]