diff --git a/.gitignore b/.gitignore index 7134020a..8fff02b2 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,8 @@ poetry.lock test_gasoilprocess.zip test_gasoilprocess.neqsim + +# Local CodeQL artifacts +codeql-db/ +codeql-*.sarif +tools/codeql/ diff --git a/README.md b/README.md index c7cdf93a..00c75a89 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,14 @@ print(f"Compressor power: {comp.getPower()/1e6:.2f} MW") See the [examples folder](https://github.com/equinor/neqsim-python/tree/master/examples) for more process simulation examples, including [processApproaches.py](https://github.com/equinor/neqsim-python/blob/master/examples/processApproaches.py) which demonstrates all four approaches. -### Prerequisites +## PVT Simulation (PVTsimulation) + +NeqSim also includes a `pvtsimulation` package for common PVT experiments (CCE/CME, CVD, differential liberation, separator tests, swelling, viscosity, etc.) and tuning workflows. + +- Documentation: `docs/pvt_simulation.md` +- Direct Java access examples: `examples/pvtsimulation/README.md` + +## Prerequisites Java version 8 or higher ([Java JDK](https://adoptium.net/)) needs to be installed. The Python package [JPype](https://github.com/jpype-project/jpype) is used to connect Python and Java. Read the [installation requirements for Jpype](https://jpype.readthedocs.io/en/latest/install.html). Be aware that mixing 64 bit Python with 32 bit Java and vice versa crashes on import of the jpype module. The needed Python packages are listed in the [NeqSim Python dependencies page](https://github.com/equinor/neqsim-python/network/dependencies). diff --git a/docs/pvt_simulation.md b/docs/pvt_simulation.md new file mode 100644 index 00000000..86dca795 --- /dev/null +++ b/docs/pvt_simulation.md @@ -0,0 +1,78 @@ +# PVTsimulation (PVT experiments, characterization, tuning) + +NeqSim’s `pvtsimulation` package contains common PVT laboratory experiments (CCE/CME, CVD, DL, separator tests, swelling, viscosity, etc.) and utilities for tuning fluid characterization to match measured data. + +This repository supports two ways of running these experiments from Python: + +1. **Python wrappers** in `neqsim.thermo` / `neqsim.thermo.thermoTools` (quickest) +2. **Direct Java access** via `from neqsim import jneqsim` (full control, more outputs, tuning) + +## Experiment coverage + +| Experiment | Java class | Python wrapper | Example | +| --- | --- | --- | --- | +| Saturation pressure (bubble/dew point) | `jneqsim.pvtsimulation.simulation.SaturationPressure` | `neqsim.thermo.saturationpressure()` | `examples/pvtsimulation/pvt_experiments_java_access.py`, `examples/pvtsimulation/pvt_tuning_to_saturation.py` | +| Constant mass expansion (CCE/CME) | `...ConstantMassExpansion` | `neqsim.thermo.CME()` | `examples/pvtsimulation/pvt_experiments_java_access.py`, `examples/pvtsimulation/pvt_tuning_cme.py` | +| Constant volume depletion (CVD) | `...ConstantVolumeDepletion` | `neqsim.thermo.CVD()` | `examples/pvtsimulation/pvt_experiments_java_access.py`, `examples/pvtsimulation/pvt_tuning_cvd.py` | +| Differential liberation (DL) | `...DifferentialLiberation` | `neqsim.thermo.difflib()` | `examples/pvtsimulation/pvt_experiments_java_access.py` | +| Separator test | `...SeparatorTest` | `neqsim.thermo.separatortest()` | `examples/pvtsimulation/pvt_experiments_java_access.py` | +| Swelling test | `...SwellingTest` | `neqsim.thermo.swellingtest()` | `examples/pvtsimulation/pvt_experiments_java_access.py` | +| Viscosity | `...ViscositySim` | `neqsim.thermo.viscositysim()` | `examples/pvtsimulation/pvt_experiments_java_access.py`, `examples/pvtsimulation/pvt_tuning_viscosity.py` | +| GOR / Bo | `...GOR` | `neqsim.thermo.GOR()` | `examples/pvtsimulation/pvt_experiments_java_access.py` | + +Other available simulations (direct Java access): + +- `jneqsim.pvtsimulation.simulation.WaxFractionSim` (wax appearance / wax fraction vs T,P; requires wax model setup) +- `jneqsim.pvtsimulation.simulation.ViscosityWaxOilSim` (wax + viscosity) +- `jneqsim.pvtsimulation.simulation.DensitySim` +- `jneqsim.pvtsimulation.simulation.SlimTubeSim` (slim-tube style displacement simulation) + +## Direct Java access: key patterns + +### Passing arrays + +Many PVTsimulation methods expect Java `double[]`. With JPype you can pass: + +- A Python list (auto-converted to `double[]` by JPype) + +Example: + +```python +from neqsim import jneqsim + +pressures = [400.0, 300.0, 200.0] +temperatures = [373.15, 373.15, 373.15] + +cme = jneqsim.pvtsimulation.simulation.ConstantMassExpansion(oil) +cme.setTemperaturesAndPressures(temperatures, pressures) +cme.runCalc() +``` + +### Experimental data for tuning + +For several experiments, NeqSim expects `double[1][n]` experimental data, i.e. a single row with `n` values aligned with your pressure/temperature points: + +```python +cme.setExperimentalData([[0.98, 1.02, 1.10]]) # relative volume values +cme.runTuning() +``` + +## Fluid characterization + PVT lumping + +See `examples/pvtsimulation/fluid_characterization_and_lumping.py` for a typical black-oil characterization workflow using: + +- `addTBPfraction(...)` and `addPlusFraction(...)` +- `getCharacterization().setLumpingModel("PVTlumpingModel")` +- `getCharacterization().characterisePlusFraction()` + +## Run the examples + +```bash +python examples/pvtsimulation/pvt_experiments_java_access.py +python examples/pvtsimulation/pvt_tuning_to_saturation.py +python examples/pvtsimulation/pvt_tuning_cme.py +python examples/pvtsimulation/pvt_tuning_cvd.py +python examples/pvtsimulation/pvt_tuning_viscosity.py +``` + +Tuning scripts default to skipping the actual tuning step; set `run_tuning = True` inside the script when you are ready to tune against measured data. diff --git a/examples/process_api.py b/examples/process_api.py new file mode 100644 index 00000000..51301e47 --- /dev/null +++ b/examples/process_api.py @@ -0,0 +1,575 @@ +""" +NeqSim Process Simulation API + +A REST API for running process simulations from YAML/JSON configurations. + +Usage: + 1. Install dependencies: + pip install fastapi uvicorn pyyaml + + 2. Run the API server: + uvicorn process_api:app --reload --port 8000 + + 3. Open API docs: + http://localhost:8000/docs + + 4. Send POST request with process configuration: + curl -X POST http://localhost:8000/simulate \\ + -H "Content-Type: application/json" \\ + -d @process_config.json + +Example request body (JSON): +{ + "name": "Simple Compression", + "fluids": { + "feed": { + "model": "srk", + "temperature": 303.15, + "pressure": 10.0, + "components": [ + {"name": "methane", "moles": 0.9}, + {"name": "ethane", "moles": 0.1} + ] + } + }, + "equipment": [ + {"type": "stream", "name": "inlet", "fluid": "feed", + "flow_rate": 5.0, "flow_unit": "MSm3/day"}, + {"type": "compressor", "name": "comp1", "inlet": "inlet", + "pressure": 50.0} + ] +} +""" + +from fastapi import FastAPI, HTTPException, Body +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field +from typing import Any, Dict, List, Optional, Union +import json +import traceback +import logging + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Initialize NeqSim (must be done before importing process modules) +try: + from neqsim.process import create_process_from_config, create_fluid_from_config + from neqsim.thermo import TPflash, dataFrame + + NEQSIM_AVAILABLE = True +except ImportError as e: + NEQSIM_AVAILABLE = False + NEQSIM_ERROR = str(e) + +# FastAPI app +app = FastAPI( + title="NeqSim Process Simulation API", + description="REST API for running process simulations from YAML/JSON configurations", + version="1.0.0", +) + +# Enable CORS for web clients +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ============================================================================= +# Pydantic Models for Request/Response +# ============================================================================= + + +class ComponentConfig(BaseModel): + """Component configuration for fluid creation.""" + + name: str = Field(..., description="Component name (e.g., 'methane', 'CO2')") + moles: Optional[float] = Field(None, description="Molar amount") + rate: Optional[float] = Field(None, description="Flow rate") + unit: Optional[str] = Field("mol/sec", description="Flow rate unit") + mole_fraction: Optional[float] = Field(None, description="Mole fraction") + + +class FluidConfig(BaseModel): + """Fluid configuration.""" + + type: Optional[str] = Field("custom", description="'predefined' or 'custom'") + name: Optional[str] = Field(None, description="Predefined fluid name") + model: Optional[str] = Field("srk", description="Equation of state") + temperature: Optional[float] = Field(298.15, description="Temperature in K") + pressure: Optional[float] = Field(1.01325, description="Pressure in bara") + components: Optional[List[ComponentConfig]] = Field( + None, description="Component list" + ) + mixing_rule: Optional[str] = Field(None, description="Mixing rule") + ge_model: Optional[str] = Field(None, description="GE model") + multiphase: Optional[bool] = Field(False, description="Enable multiphase") + solid_check: Optional[bool] = Field(False, description="Enable solid check") + + +class EquipmentConfig(BaseModel): + """Equipment configuration.""" + + type: str = Field(..., description="Equipment type (e.g., 'stream', 'compressor')") + name: str = Field(..., description="Unique equipment name") + + class Config: + extra = "allow" # Allow additional fields for equipment-specific params + + +class ProcessConfig(BaseModel): + """Complete process configuration.""" + + name: Optional[str] = Field("Process", description="Process name") + fluids: Optional[Dict[str, FluidConfig]] = Field( + None, description="Fluid definitions" + ) + equipment: List[EquipmentConfig] = Field(..., description="Equipment list") + + +class FlashRequest(BaseModel): + """Request for flash calculation.""" + + fluid: FluidConfig = Field(..., description="Fluid configuration") + temperature: Optional[float] = Field(None, description="Flash temperature in K") + pressure: Optional[float] = Field(None, description="Flash pressure in bara") + + +class SimulationResult(BaseModel): + """Simulation result response.""" + + success: bool + process_name: str + equipment_results: Dict[str, Any] + stream_data: Optional[List[Dict[str, Any]]] = None + error: Optional[str] = None + + +class FlashResult(BaseModel): + """Flash calculation result.""" + + success: bool + temperature: float + pressure: float + phases: List[Dict[str, Any]] + properties: Dict[str, Any] + error: Optional[str] = None + + +# ============================================================================= +# API Endpoints +# ============================================================================= + + +@app.get("/") +async def root(): + """API health check and info.""" + return { + "service": "NeqSim Process Simulation API", + "version": "1.0.0", + "neqsim_available": NEQSIM_AVAILABLE, + "docs_url": "/docs", + "endpoints": { + "POST /simulate": "Run process simulation from config", + "POST /flash": "Run flash calculation", + "GET /equipment-types": "List available equipment types", + "GET /fluid-models": "List available equation of state models", + }, + } + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + if not NEQSIM_AVAILABLE: + raise HTTPException( + status_code=503, detail=f"NeqSim not available: {NEQSIM_ERROR}" + ) + return {"status": "healthy", "neqsim": "available"} + + +@app.get("/equipment-types") +async def get_equipment_types(): + """Get list of available equipment types for YAML/JSON configuration.""" + equipment_types = { + "streams": [ + "stream", + "water_stream", + "neq_stream", + "energy_stream", + "well_stream", + "virtual_stream", + "stream_from_outlet", + ], + "separators": [ + "separator", + "three_phase_separator", + "gas_scrubber", + "gas_scrubber_with_options", + "separator_with_dimensions", + ], + "pressure_changers": [ + "compressor", + "pump", + "expander", + "valve", + "valve_with_options", + "compressor_with_chart", + "polytopic_compressor", + "polytropic_compressor", + ], + "heat_transfer": ["heater", "cooler", "heat_exchanger"], + "mixing_splitting": [ + "mixer", + "splitter", + "manifold", + "static_mixer", + "static_phase_mixer", + "component_splitter", + "splitter_with_flowrates", + ], + "pipelines": ["pipe", "beggs_brill_pipe", "two_phase_pipe"], + "columns": [ + "distillation_column", + "teg_absorber", + "water_stripper", + "simple_absorber", + ], + "reactors": ["reactor", "gibbs_reactor"], + "utilities": [ + "saturator", + "filter", + "calculator", + "setpoint", + "adjuster", + "ejector", + "flare", + "tank", + ], + "measurement": [ + "pressure_transmitter", + "level_transmitter", + "flow_transmitter", + "temperature_transmitter", + ], + "control": ["pid_controller", "flow_setter", "flow_rate_adjuster"], + "recycle": ["recycle", "recycle_loop", "close_recycle"], + } + return equipment_types + + +@app.get("/fluid-models") +async def get_fluid_models(): + """Get list of available equation of state models.""" + return { + "cubic_eos": [ + {"id": "srk", "name": "Soave-Redlich-Kwong"}, + {"id": "pr", "name": "Peng-Robinson"}, + {"id": "rk", "name": "Redlich-Kwong"}, + ], + "advanced_eos": [ + {"id": "cpa", "name": "CPA (Cubic Plus Association)"}, + {"id": "cpa-srk", "name": "CPA-SRK"}, + {"id": "cpa-pr", "name": "CPA-PR"}, + {"id": "gerg-2008", "name": "GERG-2008"}, + ], + "electrolyte": [ + {"id": "electrolyte", "name": "Electrolyte EoS"}, + {"id": "cpa-el", "name": "Electrolyte CPA"}, + ], + "activity_coefficient": [ + {"id": "nrtl", "name": "NRTL"}, + {"id": "unifac", "name": "UNIFAC"}, + ], + "predefined_fluids": [ + "dry gas", + "rich gas", + "light oil", + "black oil", + "water", + "air", + "combustion air", + ], + } + + +@app.post("/simulate", response_model=SimulationResult) +async def run_simulation( + config: ProcessConfig = Body( + ..., + example={ + "name": "Simple Compression", + "fluids": { + "feed": { + "model": "srk", + "temperature": 303.15, + "pressure": 10.0, + "components": [ + {"name": "methane", "moles": 0.9}, + {"name": "ethane", "moles": 0.1}, + ], + } + }, + "equipment": [ + { + "type": "stream", + "name": "inlet", + "fluid": "feed", + "flow_rate": 5.0, + "flow_unit": "MSm3/day", + }, + { + "type": "compressor", + "name": "comp1", + "inlet": "inlet", + "pressure": 50.0, + }, + ], + }, + ) +): + """ + Run a process simulation from JSON configuration. + + The configuration should include: + - name: Optional process name + - fluids: Dictionary of fluid configurations + - equipment: List of equipment in process order + + Returns simulation results including equipment data and stream properties. + """ + if not NEQSIM_AVAILABLE: + raise HTTPException( + status_code=503, detail=f"NeqSim not available: {NEQSIM_ERROR}" + ) + + try: + # Convert Pydantic model to dict + config_dict = config.model_dump(exclude_none=True) + + # Convert fluids config + if "fluids" in config_dict: + fluids_dict = {} + for name, fluid_cfg in config_dict["fluids"].items(): + fluids_dict[name] = fluid_cfg + config_dict["fluids"] = fluids_dict + + # Run simulation + process = create_process_from_config(config_dict, run=True) + + # Collect results + equipment_results = {} + for eq_name, eq_obj in process.equipment.items(): + eq_result = {"name": eq_name, "type": type(eq_obj).__name__} + + # Try to get common properties + try: + if hasattr(eq_obj, "getOutletStream"): + outlet = eq_obj.getOutletStream() + if outlet: + eq_result["outlet_temperature_K"] = outlet.getTemperature() + eq_result["outlet_pressure_bara"] = outlet.getPressure() + eq_result["outlet_flow_kg_hr"] = outlet.getFlowRate("kg/hr") + except: + pass + + try: + if hasattr(eq_obj, "getPower"): + eq_result["power_W"] = eq_obj.getPower() + eq_result["power_MW"] = eq_obj.getPower() / 1e6 + except: + pass + + try: + if hasattr(eq_obj, "getDuty"): + eq_result["duty_W"] = eq_obj.getDuty() + eq_result["duty_MW"] = eq_obj.getDuty() / 1e6 + except: + pass + + try: + if hasattr(eq_obj, "getPolytropicEfficiency"): + eq_result["polytropic_efficiency"] = ( + eq_obj.getPolytropicEfficiency() + ) + if hasattr(eq_obj, "getIsentropicEfficiency"): + eq_result["isentropic_efficiency"] = ( + eq_obj.getIsentropicEfficiency() + ) + except: + pass + + equipment_results[eq_name] = eq_result + + # Get full results as JSON + try: + full_results = process.results_json() + except: + full_results = None + + # Get stream data + stream_data = None + try: + df = process.results_dataframe() + stream_data = df.to_dict(orient="records") + except: + pass + + return SimulationResult( + success=True, + process_name=config.name or "Process", + equipment_results=equipment_results, + stream_data=stream_data, + ) + + except Exception as e: + return SimulationResult( + success=False, + process_name=config.name or "Process", + equipment_results={}, + error=f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}", + ) + + +@app.post("/flash", response_model=FlashResult) +async def run_flash( + request: FlashRequest = Body( + ..., + example={ + "fluid": { + "model": "srk", + "temperature": 303.15, + "pressure": 50.0, + "components": [ + {"name": "methane", "moles": 0.85}, + {"name": "ethane", "moles": 0.10}, + {"name": "propane", "moles": 0.05}, + ], + }, + "temperature": 280.0, + "pressure": 30.0, + }, + ) +): + """ + Run a TP flash calculation on a fluid. + + Returns phase properties and compositions. + """ + if not NEQSIM_AVAILABLE: + raise HTTPException( + status_code=503, detail=f"NeqSim not available: {NEQSIM_ERROR}" + ) + + try: + # Create fluid + fluid_config = request.fluid.model_dump(exclude_none=True) + fluid = create_fluid_from_config(fluid_config) + + # Set conditions + if request.temperature: + fluid.setTemperature(request.temperature) + if request.pressure: + fluid.setPressure(request.pressure) + + # Run flash + TPflash(fluid) + + # Collect phase results + phases = [] + for i in range(fluid.getNumberOfPhases()): + phase = fluid.getPhase(i) + phase_data = { + "phase_number": i, + "phase_type": str(phase.getPhaseTypeName()), + "mole_fraction": fluid.getBeta(i), + "temperature_K": phase.getTemperature(), + "pressure_bara": phase.getPressure(), + "density_kg_m3": phase.getDensity("kg/m3"), + "molar_mass_kg_kmol": phase.getMolarMass() * 1000, + "Z_factor": phase.getZ(), + "viscosity_Pa_s": phase.getViscosity("kg/msec"), + "components": {}, + } + + for j in range(phase.getNumberOfComponents()): + comp = phase.getComponent(j) + phase_data["components"][comp.getName()] = { + "mole_fraction": comp.getx(), + "fugacity_coefficient": comp.getFugacityCoefficient(), + } + + phases.append(phase_data) + + # Overall properties + properties = { + "number_of_phases": fluid.getNumberOfPhases(), + "enthalpy_J_mol": fluid.getEnthalpy() / fluid.getTotalNumberOfMoles(), + "entropy_J_mol_K": fluid.getEntropy() / fluid.getTotalNumberOfMoles(), + } + + return FlashResult( + success=True, + temperature=fluid.getTemperature(), + pressure=fluid.getPressure(), + phases=phases, + properties=properties, + ) + + except Exception as e: + return FlashResult( + success=False, + temperature=request.temperature or 0, + pressure=request.pressure or 0, + phases=[], + properties={}, + error=f"{type(e).__name__}: {str(e)}", + ) + + +@app.post("/simulate/yaml") +async def run_simulation_yaml(yaml_content: str = Body(..., media_type="text/plain")): + """ + Run a process simulation from YAML content. + + Send raw YAML as the request body with Content-Type: text/plain + """ + if not NEQSIM_AVAILABLE: + raise HTTPException( + status_code=503, detail=f"NeqSim not available: {NEQSIM_ERROR}" + ) + + try: + import yaml + + config = yaml.safe_load(yaml_content) + + # Run simulation + process = create_process_from_config(config, run=True) + + # Return results as JSON + return {"success": True, "results": process.results_json()} + + except ImportError: + raise HTTPException(status_code=500, detail="PyYAML not installed") + except Exception as e: + logger.error("Exception in /simulate/yaml endpoint", exc_info=True) + return {"success": False, "error": "An internal error occurred"} + + +# ============================================================================= +# Main Entry Point +# ============================================================================= + + +if __name__ == "__main__": + import uvicorn + + print("Starting NeqSim Process Simulation API...") + print("API documentation available at: http://localhost:8000/docs") + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/pvtsimulation/README.md b/examples/pvtsimulation/README.md new file mode 100644 index 00000000..1a0ec7ae --- /dev/null +++ b/examples/pvtsimulation/README.md @@ -0,0 +1,20 @@ +# PVTsimulation examples (direct Java access) + +These examples use **direct Java access** (`from neqsim import jneqsim`) for full control of NeqSim's `pvtsimulation` package. + +Files: + +- `pvt_experiments_java_access.py`: Run common PVT experiments (Psat, CME, CVD, DL, separator test, swelling, viscosity, GOR). +- `pvt_tuning_to_saturation.py`: Tune plus-fraction molar mass to match a measured saturation pressure (bubble/dew point). +- `pvt_tuning_cme.py`: Tune to match experimental CME relative-volume data. +- `pvt_tuning_cvd.py`: Tune to match experimental CVD relative-volume data. +- `pvt_tuning_viscosity.py`: Tune to match experimental viscosity data. +- `fluid_characterization_and_lumping.py`: Typical black-oil characterization workflow (TBP/plus fraction + PVT lumping). + +Run: + +```bash +python examples/pvtsimulation/pvt_experiments_java_access.py +``` + +Tuning scripts default to skipping the actual tuning step; set `run_tuning = True` inside the script when you are ready to tune against measured data. diff --git a/examples/pvtsimulation/fluid_characterization_and_lumping.py b/examples/pvtsimulation/fluid_characterization_and_lumping.py new file mode 100644 index 00000000..d4c3e2e8 --- /dev/null +++ b/examples/pvtsimulation/fluid_characterization_and_lumping.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +""" +Fluid characterization + PVT lumping (black oil style) +===================================================== + +This example shows a typical characterization workflow when you have: + - Light ends as normal components (N2/CO2/C1.../C6) + - Heavy end as TBP cuts and a final plus fraction (e.g. C11+) + +Key steps: + 1) Add TBP fractions and a plus fraction with molar mass and density + 2) Select PVT lumping model (PVTlumpingModel) and number of pseudo components + 3) `characterisePlusFraction()` to split/lump the plus fraction into pseudo components + 4) Create database + set mixing rule + enable volume correction + +You can then run PVTsimulation experiments (CCE/CVD/DL/sep test) on the characterized fluid. +""" + +from __future__ import annotations + +from neqsim.thermo import TPflash, fluid, printFrame + + +def main() -> None: + oil = fluid("srk", 273.15 + 100.0, 250.0) + + oil.addComponent("nitrogen", 0.34) + oil.addComponent("CO2", 3.59) + oil.addComponent("methane", 67.42) + oil.addComponent("ethane", 9.02) + oil.addComponent("propane", 4.31) + oil.addComponent("i-butane", 0.93) + oil.addComponent("n-butane", 1.71) + oil.addComponent("i-pentane", 0.74) + oil.addComponent("n-pentane", 0.85) + oil.addComponent("n-hexane", 1.38) + + oil.addTBPfraction("C7", 1.50, 109.00 / 1000.0, 0.6912) + oil.addTBPfraction("C8", 1.69, 120.20 / 1000.0, 0.7255) + oil.addTBPfraction("C9", 1.14, 129.50 / 1000.0, 0.7454) + oil.addTBPfraction("C10", 0.80, 135.30 / 1000.0, 0.7864) + oil.addPlusFraction("C11", 4.58, 256.20 / 1000.0, 0.8398) + + oil.getCharacterization().setLumpingModel("PVTlumpingModel") + oil.getCharacterization().getLumpingModel().setNumberOfPseudoComponents(12) + oil.getCharacterization().characterisePlusFraction() + + oil.createDatabase(True) + oil.setMixingRule(2) + oil.setMultiPhaseCheck(True) + oil.useVolumeCorrection(True) + + TPflash(oil) + printFrame(oil) + + lumping = oil.getCharacterization().getLumpingModel() + n_lumped = int(lumping.getNumberOfLumpedComponents()) + print(f"\nNumber of lumped components: {n_lumped}") + for i in range(n_lumped): + print(f"{i:2d}: {lumping.getLumpedComponentName(i)}") + + +if __name__ == "__main__": + main() diff --git a/examples/pvtsimulation/pvt_experiments_java_access.py b/examples/pvtsimulation/pvt_experiments_java_access.py new file mode 100644 index 00000000..11f50569 --- /dev/null +++ b/examples/pvtsimulation/pvt_experiments_java_access.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +""" +PVTsimulation experiments (direct Java access) +============================================ + +This script demonstrates NeqSim's PVTsimulation package using direct Java access: + + from neqsim import jneqsim + +Covered experiments (typical PVT lab / reservoir studies): + - Saturation pressure (bubble/dew point): SaturationPressure + - Constant mass expansion (CCE/CME): ConstantMassExpansion + - Constant volume depletion (CVD): ConstantVolumeDepletion + - Differential liberation (DL): DifferentialLiberation + - Separator test: SeparatorTest + - Swelling test: SwellingTest + - Viscosity study: ViscositySim + - GOR / Bo vs pressure: GOR + +Notes: + - NeqSim pressures are in bara (bar absolute) unless you explicitly manage units. + - Temperatures here are in Kelvin when passed to PVTsimulation classes. + - For simpler usage, see wrapper functions in `neqsim.thermo.thermoTools`. +""" + +from __future__ import annotations + +from neqsim import jneqsim +from neqsim.thermo import TPflash, fluid + + +def _as_list(java_array) -> list[float]: + return [float(x) for x in java_array] + + +def _build_oil_for_pvt_experiments(): + oil = fluid("srk") + oil.addComponent("methane", 50.0) + oil.addComponent("ethane", 10.0) + oil.addComponent("propane", 5.0) + oil.addComponent("n-butane", 5.0) + oil.addComponent("n-hexane", 10.0) + oil.addPlusFraction("C20", 20.0, 381.0 / 1000.0, 0.88) + + oil.createDatabase(True) + oil.setMixingRule(2) # "classic" (kij from DB) + oil.setMultiPhaseCheck(True) + oil.useVolumeCorrection(True) + return oil + + +def main() -> None: + oil = _build_oil_for_pvt_experiments() + + reservoir_temperature_k = 273.15 + 100.0 + oil.setTemperature(reservoir_temperature_k) + oil.setPressure(250.0) + TPflash(oil) + + print("\n--- Saturation pressure ---") + sat = jneqsim.pvtsimulation.simulation.SaturationPressure(oil.clone()) + sat.setTemperature(reservoir_temperature_k, "K") + sat.run() + psat = float(sat.getSaturationPressure()) + print(f"Psat @ {reservoir_temperature_k:.2f} K: {psat:.3f} bara") + + pressures = [400.0, 350.0, 300.0, 250.0, 200.0, 150.0, 100.0, 50.0] + temperatures = [reservoir_temperature_k] * len(pressures) + + print("\n--- Constant Mass Expansion (CCE/CME) ---") + cme = jneqsim.pvtsimulation.simulation.ConstantMassExpansion(oil.clone()) + cme.setTemperaturesAndPressures(temperatures, pressures) + cme.setTemperature(reservoir_temperature_k, "K") + cme.runCalc() + print("relativeVolume:", _as_list(cme.getRelativeVolume())) + print("liquidRelativeVolume:", _as_list(cme.getLiquidRelativeVolume())) + print("Yfactor:", _as_list(cme.getYfactor())) + print( + "isoThermalCompressibility [1/bar]:", + _as_list(cme.getIsoThermalCompressibility()), + ) + + print("\n--- Constant Volume Depletion (CVD) ---") + cvd = jneqsim.pvtsimulation.simulation.ConstantVolumeDepletion(oil.clone()) + cvd.setPressures(pressures) + cvd.setTemperature(reservoir_temperature_k, "K") + cvd.runCalc() + print("relativeVolume:", _as_list(cvd.getRelativeVolume())) + print("liquidRelativeVolume:", _as_list(cvd.getLiquidRelativeVolume())) + print( + "cummulativeMolePercDepleted [%]:", + _as_list(cvd.getCummulativeMolePercDepleted()), + ) + + print("\n--- Differential Liberation (DL) ---") + dl = jneqsim.pvtsimulation.simulation.DifferentialLiberation(oil.clone()) + dl.setPressures(pressures + [1.01325]) + dl.setTemperature(reservoir_temperature_k, "K") + dl.runCalc() + print("Bo [m3/Sm3]:", _as_list(dl.getBo())) + print("Rs [Sm3/Sm3]:", _as_list(dl.getRs())) + print("oilDensity [kg/m3]:", _as_list(dl.getOilDensity())) + + print("\n--- Separator test ---") + sep_pressures = [50.0, 10.0, 1.01325] + sep_temperatures = [313.15, 303.15, 293.15] + sep = jneqsim.pvtsimulation.simulation.SeparatorTest(oil.clone()) + sep.setSeparatorConditions(sep_temperatures, sep_pressures) + sep.runCalc() + print("separator GOR [Sm3/Sm3]:", _as_list(sep.getGOR())) + print("separator Bo [m3/Sm3]:", _as_list(sep.getBofactor())) + + print("\n--- Swelling test ---") + injection_gas = fluid("srk") + injection_gas.addComponent("CO2", 100.0) + injection_gas.createDatabase(True) + injection_gas.setMixingRule(2) + + mol_percent_injected = [0.0, 1.0, 5.0, 10.0, 20.0] + swell = jneqsim.pvtsimulation.simulation.SwellingTest(oil.clone()) + swell.setInjectionGas(injection_gas) + swell.setTemperature(reservoir_temperature_k, "K") + swell.setCummulativeMolePercentGasInjected(mol_percent_injected) + swell.runCalc() + print("swell pressures [bara]:", _as_list(swell.getPressures())) + print("relativeOilVolume [-]:", _as_list(swell.getRelativeOilVolume())) + + print("\n--- Viscosity study ---") + visc = jneqsim.pvtsimulation.simulation.ViscositySim(oil.clone()) + visc.setTemperaturesAndPressures(temperatures, pressures) + visc.runCalc() + print("oilViscosity [Pa*s]:", _as_list(visc.getOilViscosity())) + print("gasViscosity [Pa*s]:", _as_list(visc.getGasViscosity())) + + print("\n--- GOR / Bo vs pressure ---") + gor = jneqsim.pvtsimulation.simulation.GOR(oil.clone()) + gor.setTemperaturesAndPressures(temperatures, pressures) + gor.runCalc() + print("GOR [Sm3/Sm3]:", _as_list(gor.getGOR())) + print("Bo [m3/Sm3]:", _as_list(gor.getBofactor())) + + print("\nDone.") + + +if __name__ == "__main__": + main() diff --git a/examples/pvtsimulation/pvt_tuning_cme.py b/examples/pvtsimulation/pvt_tuning_cme.py new file mode 100644 index 00000000..7fe3ef68 --- /dev/null +++ b/examples/pvtsimulation/pvt_tuning_cme.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" +Tune to CME (CCE) relative-volume data +===================================== + +This example uses `pvtsimulation.simulation.ConstantMassExpansion` with: + + - `setTemperaturesAndPressures(temps, pressures)` + - `setExperimentalData([[...relative volumes...]]])` + - `runTuning()` + +NeqSim's CME tuning currently uses the optimizer to adjust plus-fraction properties +(notably plus-fraction molar mass) to better match the experimental relative-volume curve. +""" + +from __future__ import annotations + +from neqsim import jneqsim +from neqsim.thermo import fluid + + +def _build_oil_with_plus_fraction(): + oil = fluid("srk") + oil.addComponent("methane", 50.0) + oil.addComponent("ethane", 10.0) + oil.addComponent("propane", 5.0) + oil.addComponent("n-butane", 5.0) + oil.addComponent("n-hexane", 10.0) + oil.addPlusFraction("C20", 20.0, 381.0 / 1000.0, 0.88) + + oil.createDatabase(True) + oil.setMixingRule(2) + oil.setMultiPhaseCheck(True) + oil.useVolumeCorrection(True) + return oil + + +def _as_list(java_array) -> list[float]: + return [float(x) for x in java_array] + + +def main() -> None: + run_tuning = False # set True when tuning against measured CME data + + oil = _build_oil_with_plus_fraction() + + temperature_k = 273.15 + 80.0 + pressures = [400.0, 350.0, 300.0, 250.0, 200.0, 150.0] + temperatures = [temperature_k] * len(pressures) + + # Example data (replace with lab CCE/CME data aligned with the pressures above). + # Format expected by NeqSim: double[1][n], i.e. a single row of n data points. + exp_relative_volume = [0.98, 1.02, 1.08, 1.18, 1.38, 1.80] + + cme = jneqsim.pvtsimulation.simulation.ConstantMassExpansion(oil) + cme.setTemperature(temperature_k, "K") + cme.setTemperaturesAndPressures(temperatures, pressures) + + cme.runCalc() + rv_before = _as_list(cme.getRelativeVolume()) + + print("CME relativeVolume before tuning:", rv_before) + + if not run_tuning: + print( + "Skipping tuning (set `run_tuning = True` to run `ConstantMassExpansion.runTuning`). " + "Note: tuning convergence depends on data/initial fluid and may fail for some cases." + ) + return + + cme.setExperimentalData([exp_relative_volume]) + cme.getOptimizer().setMaxNumberOfIterations(10) + try: + cme.runTuning() + except Exception as exc: + print(f"Tuning failed: {exc}") + return + + rv_after = _as_list(cme.getRelativeVolume()) + print("CME relativeVolume after tuning: ", rv_after) + + +if __name__ == "__main__": + main() diff --git a/examples/pvtsimulation/pvt_tuning_cvd.py b/examples/pvtsimulation/pvt_tuning_cvd.py new file mode 100644 index 00000000..d71828c1 --- /dev/null +++ b/examples/pvtsimulation/pvt_tuning_cvd.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +""" +Tune to CVD relative-volume data +=============================== + +This example uses `pvtsimulation.simulation.ConstantVolumeDepletion` with: + + - `setTemperaturesAndPressures(temps, pressures)` (for tuning) + - `setExperimentalData([[...relative volumes...]]])` + - `runTuning()` + +Replace the example data with your lab CVD relative volumes aligned with the pressures. +""" + +from __future__ import annotations + +from neqsim import jneqsim +from neqsim.thermo import fluid + + +def _build_oil_with_plus_fraction(): + oil = fluid("srk") + oil.addComponent("methane", 50.0) + oil.addComponent("ethane", 10.0) + oil.addComponent("propane", 5.0) + oil.addComponent("n-butane", 5.0) + oil.addComponent("n-hexane", 10.0) + oil.addPlusFraction("C20", 20.0, 381.0 / 1000.0, 0.88) + + oil.createDatabase(True) + oil.setMixingRule(2) + oil.setMultiPhaseCheck(True) + oil.useVolumeCorrection(True) + return oil + + +def _as_list(java_array) -> list[float]: + return [float(x) for x in java_array] + + +def main() -> None: + run_tuning = False # set True when tuning against measured CVD data + + oil = _build_oil_with_plus_fraction() + + temperature_k = 273.15 + 80.0 + pressures = [400.0, 350.0, 300.0, 250.0, 200.0, 150.0, 100.0] + temperatures = [temperature_k] * len(pressures) + + exp_relative_volume = [0.96, 0.98, 1.00, 1.05, 1.15, 1.30, 1.60] + + cvd = jneqsim.pvtsimulation.simulation.ConstantVolumeDepletion(oil) + cvd.setTemperature(temperature_k, "K") + cvd.setTemperaturesAndPressures(temperatures, pressures) + + cvd.runCalc() + rv_before = _as_list(cvd.getRelativeVolume()) + + print("CVD relativeVolume before tuning:", rv_before) + + if not run_tuning: + print( + "Skipping tuning (set `run_tuning = True` to run `ConstantVolumeDepletion.runTuning`). " + "Note: tuning convergence depends on data/initial fluid and may fail for some cases." + ) + return + + cvd.setExperimentalData([exp_relative_volume]) + cvd.getOptimizer().setMaxNumberOfIterations(20) + try: + cvd.runTuning() + except Exception as exc: + print(f"Tuning failed: {exc}") + return + + rv_after = _as_list(cvd.getRelativeVolume()) + print("CVD relativeVolume after tuning: ", rv_after) + + +if __name__ == "__main__": + main() diff --git a/examples/pvtsimulation/pvt_tuning_to_saturation.py b/examples/pvtsimulation/pvt_tuning_to_saturation.py new file mode 100644 index 00000000..06e2f1eb --- /dev/null +++ b/examples/pvtsimulation/pvt_tuning_to_saturation.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +""" +Tune plus fraction to measured saturation pressure +================================================== + +This example uses: + - `pvtsimulation.simulation.SaturationPressure` to calculate saturation pressure + - `pvtsimulation.modeltuning.TuneToSaturation` to tune plus-fraction molar mass + +Typical use: + - You have a measured bubble-point (oil) or dew-point (gas condensate) pressure at a given T. + - You want the NeqSim characterized fluid to match that saturation point before tuning against + full PVT experiments (CCE/CVD/DL, etc). +""" + +from __future__ import annotations + +from neqsim import jneqsim +from neqsim.thermo import fluid + + +def _build_oil_with_plus_fraction(): + oil = fluid("srk") + oil.addComponent("methane", 50.0) + oil.addComponent("ethane", 10.0) + oil.addComponent("propane", 5.0) + oil.addComponent("n-butane", 5.0) + oil.addComponent("n-hexane", 10.0) + oil.addPlusFraction("C20", 20.0, 381.0 / 1000.0, 0.88) + + oil.createDatabase(True) + oil.setMixingRule(2) + oil.setMultiPhaseCheck(True) + oil.useVolumeCorrection(True) + return oil + + +def main() -> None: + run_tuning = False # set True when tuning against measured Psat + + oil = _build_oil_with_plus_fraction() + + measured_temperature_k = 273.15 + 80.0 + + sat_before = jneqsim.pvtsimulation.simulation.SaturationPressure(oil.clone()) + sat_before.setTemperature(measured_temperature_k, "K") + sat_before.run() + psat_before = float(sat_before.getSaturationPressure()) + + # Default target: saturation pressure of the *characterized* version of the same fluid. + # Replace with lab bubble/dew point (keep reasonably close to the initial model to avoid + # unrealistic tuned heavy-end properties). + oil_target = oil.clone() + oil_target.getCharacterization().characterisePlusFraction() + oil_target.createDatabase(True) + oil_target.setMixingRule(2) + oil_target.setMultiPhaseCheck(True) + + sat_target = jneqsim.pvtsimulation.simulation.SaturationPressure(oil_target) + sat_target.setTemperature(measured_temperature_k, "K") + sat_target.run() + measured_psat_bara = float(sat_target.getSaturationPressure()) + + print(f"Psat before tuning: {psat_before:.3f} bara") + print(f"Psat target: {measured_psat_bara:.3f} bara") + + if not run_tuning: + print( + "Skipping tuning (set `run_tuning = True` to run `TuneToSaturation`). " + "Note: this is an experimental heuristic and may not converge for all fluids." + ) + return + + sat_sim = jneqsim.pvtsimulation.simulation.SaturationPressure(oil) + sat_sim.setTemperature(measured_temperature_k, "K") + + tuning = jneqsim.pvtsimulation.modeltuning.TuneToSaturation(sat_sim) + tuning.setSaturationConditions(measured_temperature_k, measured_psat_bara) + tuning.setTunePlusMolarMass(True) + tuning.setTuneVolumeCorrection(False) + tuning.run() + + sat_after = jneqsim.pvtsimulation.simulation.SaturationPressure( + tuning.getSimulation().getThermoSystem() + ) + sat_after.setTemperature(measured_temperature_k, "K") + sat_after.run() + psat_after = float(sat_after.getSaturationPressure()) + + print(f"Psat after tuning: {psat_after:.3f} bara") + print("Tuned system available as: tuning.getSimulation().getThermoSystem()") + + +if __name__ == "__main__": + main() diff --git a/examples/pvtsimulation/pvt_tuning_viscosity.py b/examples/pvtsimulation/pvt_tuning_viscosity.py new file mode 100644 index 00000000..91ff2c30 --- /dev/null +++ b/examples/pvtsimulation/pvt_tuning_viscosity.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +""" +Tune to viscosity data +===================== + +This example uses `pvtsimulation.simulation.ViscositySim` with: + + - `setTemperaturesAndPressures(temps, pressures)` + - `setExperimentalData([[...viscosity...]]])` + - `runTuning()` + +Replace the example data with your measured viscosities. +""" + +from __future__ import annotations + +from neqsim import jneqsim +from neqsim.thermo import fluid + + +def _build_oil_for_viscosity(): + oil = fluid("srk") + oil.addComponent("n-heptane", 6.78) + oil.addPlusFraction("C20", 10.62, 381.0 / 1000.0, 0.88) + oil.getCharacterization().characterisePlusFraction() + oil.createDatabase(True) + oil.setMixingRule(2) + oil.setMultiPhaseCheck(True) + oil.useVolumeCorrection(True) + return oil + + +def _as_list(java_array) -> list[float]: + return [float(x) for x in java_array] + + +def main() -> None: + run_tuning = False # set True when tuning against measured viscosities + + oil = _build_oil_for_viscosity() + + temperatures_k = [300.15, 293.15, 283.15, 273.15] + pressures_bara = [5.0, 5.0, 5.0, 5.0] + + # Example viscosity data in Pa*s (replace with lab values). + exp_viscosity = [2.0e-4, 2.8e-4, 4.0e-4, 5.5e-4] + + visc = jneqsim.pvtsimulation.simulation.ViscositySim(oil) + visc.setTemperaturesAndPressures(temperatures_k, pressures_bara) + visc.runCalc() + mu_before = _as_list(visc.getOilViscosity()) + + print("oil viscosity before tuning [Pa*s]:", mu_before) + + if not run_tuning: + print( + "Skipping tuning (set `run_tuning = True` to run `ViscositySim.runTuning`). " + "Note: tuning convergence depends on data/initial fluid and may fail for some cases." + ) + return + + visc.setExperimentalData([exp_viscosity]) + visc.getOptimizer().setMaxNumberOfIterations(20) + try: + visc.runTuning() + except Exception as exc: + print(f"Tuning failed: {exc}") + return + visc.runCalc() + mu_after = _as_list(visc.getOilViscosity()) + + print("oil viscosity after tuning [Pa*s]:", mu_after) + + +if __name__ == "__main__": + main() diff --git a/src/neqsim/process/processTools.py b/src/neqsim/process/processTools.py index 353e6d11..c38f90c2 100644 --- a/src/neqsim/process/processTools.py +++ b/src/neqsim/process/processTools.py @@ -157,10 +157,11 @@ from __future__ import annotations import json +import os +from pathlib import Path from typing import Any, Optional, List, Dict, Union import pandas as pd -from jpype.types import JDouble from jpype.types import * from neqsim import jneqsim @@ -169,6 +170,71 @@ _loop_mode: bool = False +_YAML_SUFFIXES = {".yaml", ".yml"} + + +def _as_float_list(values) -> list[float]: + if values is None: + return [] + if hasattr(values, "tolist"): + values = values.tolist() + return [float(v) for v in list(values)] + + +def _as_float_matrix(values) -> list[list[float]]: + if values is None: + return [] + if hasattr(values, "tolist"): + values = values.tolist() + return [[float(v) for v in row] for row in list(values)] + + +def _resolve_path_in_cwd( + user_path: str, + *, + allowed_suffixes: Optional[set[str]] = None, + must_exist: bool = False, +) -> Path: + """ + Resolve a user-supplied path safely inside the current working directory. + + This is used for convenience helpers that read/write local config/result files. + To avoid path traversal / arbitrary file read/write, absolute paths and paths that + escape the current working directory are rejected. + """ + if not isinstance(user_path, str): + raise TypeError("path must be a string") + + if "\x00" in user_path: + raise ValueError("Path contains NUL byte.") + + base_dir = os.path.abspath(os.getcwd()) + resolved_str = os.path.abspath(os.path.join(base_dir, user_path)) + + # Ensure the normalized path is still within the base directory. + # Using `startswith` on normalized paths is recognized by CodeQL as a safe-access check. + base_prefix = base_dir + os.sep + if resolved_str.startswith(base_prefix): + pass + else: + raise ValueError( + "Path traversal outside the current working directory is not allowed." + ) + + resolved = Path(resolved_str) + + if allowed_suffixes is not None: + suffix = resolved.suffix.lower() + if suffix not in allowed_suffixes: + allowed = ", ".join(sorted(allowed_suffixes)) + raise ValueError(f"Invalid file extension '{suffix}'. Allowed: {allowed}.") + + if must_exist and not resolved.is_file(): + raise FileNotFoundError(f"File not found: {resolved}") + + return resolved + + class ProcessContext: """ Context manager for explicit process simulation management. @@ -3108,7 +3174,10 @@ def from_json( >>> process = ProcessBuilder.from_json('process_config.json', ... fluids={'feed': my_fluid}).run() """ - with open(json_path, "r") as f: + json_file = _resolve_path_in_cwd( + json_path, allowed_suffixes={".json"}, must_exist=True + ) + with json_file.open("r", encoding="utf-8") as f: config = json.load(f) return cls.from_dict(config, fluids) @@ -3149,7 +3218,10 @@ def from_yaml( "PyYAML is required for YAML support. Install with: pip install pyyaml" ) - with open(yaml_path, "r") as f: + yaml_file = _resolve_path_in_cwd( + yaml_path, allowed_suffixes=_YAML_SUFFIXES, must_exist=True + ) + with yaml_file.open("r", encoding="utf-8") as f: config = yaml.safe_load(f) return cls.from_dict(config, fluids) @@ -3929,18 +4001,26 @@ def save_results(self, filename: str, format: str = "json") -> "ProcessBuilder": >>> process.save_results('results.xlsx', format='excel') """ if format == "json": - with open(filename, "w") as f: + out_file = _resolve_path_in_cwd(filename, allowed_suffixes={".json"}) + out_file.parent.mkdir(parents=True, exist_ok=True) + with out_file.open("w", encoding="utf-8") as f: json.dump(self.results_json(), f, indent=2) elif format == "csv": - self.results_dataframe().to_csv(filename, index=False) + out_file = _resolve_path_in_cwd(filename, allowed_suffixes={".csv"}) + out_file.parent.mkdir(parents=True, exist_ok=True) + self.results_dataframe().to_csv(str(out_file), index=False) elif format == "excel": - self.results_dataframe().to_excel(filename, index=False) + out_file = _resolve_path_in_cwd( + filename, allowed_suffixes={".xlsx", ".xls"} + ) + out_file.parent.mkdir(parents=True, exist_ok=True) + self.results_dataframe().to_excel(str(out_file), index=False) else: raise ValueError( f"Unknown format: {format}. Use 'json', 'csv', or 'excel'." ) - print(f"Results saved to {filename}") + print(f"Results saved to {out_file}") return self @@ -4130,7 +4210,8 @@ def glycoldehydrationlmodule(name, teststream): def openprocess(filename): - processoperations = jneqsim.process.processmodel.ProcessSystem.open(filename) + file_path = _resolve_path_in_cwd(filename, must_exist=True) + processoperations = jneqsim.process.processmodel.ProcessSystem.open(str(file_path)) return processoperations @@ -4306,35 +4387,37 @@ def compressor( def compressorChart(compressor, curveConditions, speed, flow, head, polyEff): compressor.getCompressorChart().setCurves( - JDouble[:](curveConditions), - JDouble[:](speed), - JDouble[:][:](flow), - JDouble[:][:](head), - JDouble[:][:](polyEff), + _as_float_list(curveConditions), + _as_float_list(speed), + _as_float_matrix(flow), + _as_float_matrix(head), + _as_float_matrix(polyEff), ) def pumpChart(pump, curveConditions, speed, flow, head, polyEff): pump.getPumpChart().setCurves( - JDouble[:](curveConditions), - JDouble[:](speed), - JDouble[:][:](flow), - JDouble[:][:](head), - JDouble[:][:](polyEff), + _as_float_list(curveConditions), + _as_float_list(speed), + _as_float_matrix(flow), + _as_float_matrix(head), + _as_float_matrix(polyEff), ) def compressorSurgeCurve(compressor, curveConditions, surgeflow, surgehead): compressor.getCompressorChart().getSurgeCurve().setCurve( - JDouble[:](curveConditions), JDouble[:](surgeflow), JDouble[:](surgehead) + _as_float_list(curveConditions), + _as_float_list(surgeflow), + _as_float_list(surgehead), ) def compressorStoneWallCurve(compressor, curveConditions, stoneWallflow, stoneWallHead): compressor.getCompressorChart().getStoneWallCurve().setCurve( - JDouble[:](curveConditions), - JDouble[:](stoneWallflow), - JDouble[:](stoneWallHead), + _as_float_list(curveConditions), + _as_float_list(stoneWallflow), + _as_float_list(stoneWallHead), ) @@ -4468,7 +4551,7 @@ def splitter( spl = jneqsim.process.equipment.splitter.Splitter(name, teststream) if splitfactors is not None and len(splitfactors) > 0: spl.setSplitNumber(len(splitfactors)) - spl.setSplitFactors(JDouble[:](splitfactors)) + spl.setSplitFactors(_as_float_list(splitfactors)) _add_to_process(spl, process) return spl @@ -4808,9 +4891,11 @@ def results_json(process, filename=None): # Save to file if a filename is provided if filename: - with open(filename, "w") as json_file: + out_file = _resolve_path_in_cwd(filename, allowed_suffixes={".json"}) + out_file.parent.mkdir(parents=True, exist_ok=True) + with out_file.open("w", encoding="utf-8") as json_file: json.dump(results, json_file, indent=4) - print(f"JSON report saved to {filename}") + print(f"JSON report saved to {out_file}") return results except Exception as e: @@ -5566,7 +5651,8 @@ def create_process_from_config( or use pre-created fluid objects. Args: - config: Either a path to a YAML file or a configuration dictionary. + config: Either a path to a YAML file (relative to the current working + directory) or a configuration dictionary. fluids: Optional dictionary mapping fluid names to fluid objects. If the config includes a 'fluids' section, fluids are created automatically and merged with this dictionary. @@ -5709,7 +5795,10 @@ def create_process_from_config( "PyYAML is required for YAML support. Install with: pip install pyyaml" ) - with open(config, "r") as f: + yaml_file = _resolve_path_in_cwd( + config, allowed_suffixes=_YAML_SUFFIXES, must_exist=True + ) + with yaml_file.open("r", encoding="utf-8") as f: config = yaml.safe_load(f) # Initialize fluids dictionary @@ -5738,7 +5827,7 @@ def load_process_config(yaml_path: str) -> Dict[str, Any]: Useful for inspecting or modifying configurations before building. Args: - yaml_path: Path to YAML configuration file. + yaml_path: Path to YAML configuration file (relative to the current working directory). Returns: Dictionary with the configuration. @@ -5757,7 +5846,10 @@ def load_process_config(yaml_path: str) -> Dict[str, Any]: "PyYAML is required for YAML support. Install with: pip install pyyaml" ) - with open(yaml_path, "r") as f: + yaml_file = _resolve_path_in_cwd( + yaml_path, allowed_suffixes=_YAML_SUFFIXES, must_exist=True + ) + with yaml_file.open("r", encoding="utf-8") as f: return yaml.safe_load(f) @@ -5767,7 +5859,7 @@ def save_process_config(config: Dict[str, Any], yaml_path: str) -> None: Args: config: Configuration dictionary. - yaml_path: Path to save the YAML file. + yaml_path: Path to save the YAML file (relative to the current working directory). Example: >>> config = { @@ -5783,5 +5875,7 @@ def save_process_config(config: Dict[str, Any], yaml_path: str) -> None: "PyYAML is required for YAML support. Install with: pip install pyyaml" ) - with open(yaml_path, "w") as f: + yaml_file = _resolve_path_in_cwd(yaml_path, allowed_suffixes=_YAML_SUFFIXES) + yaml_file.parent.mkdir(parents=True, exist_ok=True) + with yaml_file.open("w", encoding="utf-8") as f: yaml.dump(config, f, default_flow_style=False, sort_keys=False) diff --git a/src/neqsim/thermo/thermoTools.py b/src/neqsim/thermo/thermoTools.py index 7339ea04..08b25694 100644 --- a/src/neqsim/thermo/thermoTools.py +++ b/src/neqsim/thermo/thermoTools.py @@ -277,6 +277,23 @@ logger = logging.getLogger(__name__) + +def _as_float_list(values) -> list[float]: + if values is None: + return [] + if hasattr(values, "tolist"): + values = values.tolist() + return [float(v) for v in list(values)] + + +def _as_float_matrix(values) -> list[list[float]]: + if values is None: + return [] + if hasattr(values, "tolist"): + values = values.tolist() + return [[float(v) for v in row] for row in list(values)] + + if has_matplotlib(): import matplotlib.pyplot as plt @@ -487,8 +504,8 @@ def createfluid2(names, molefractions=None, unit="mol/sec"): Fluid: The created fluid object. """ if molefractions is None: - fluidcreator.create2(JString[:](names)) - return fluidcreator.create2(JString[:](names), JDouble[:](molefractions), unit) + return fluidcreator.create2(JString[:](names)) + return fluidcreator.create2(JString[:](names), _as_float_list(molefractions), unit) def addOilFractions( @@ -503,9 +520,9 @@ def addOilFractions( ): fluid.addOilFractions( JString[:](charNames), - JDouble[:](molefractions), - JDouble[:](molarMass), - JDouble[:](density), + _as_float_list(molefractions), + _as_float_list(molarMass), + _as_float_list(density), lastIsPlusFraction, lumpComponents, numberOfPseudoComponents, @@ -522,8 +539,10 @@ def tunewaxmodel(fluid, experimentaldata, maxiterations=5): expList = [[x * 100.0 for x in experimentaldata["experiment"]]] waxsim = jneqsim.pvtsimulation.simulation.WaxFractionSim(fluid) - waxsim.setTemperaturesAndPressures(JDouble[:](tempList), JDouble[:](presList)) - waxsim.setExperimentalData(JDouble[:, :](expList)) + waxsim.setTemperaturesAndPressures( + _as_float_list(tempList), _as_float_list(presList) + ) + waxsim.setExperimentalData(_as_float_matrix(expList)) waxsim.getOptimizer().setNumberOfTuningParameters(3) waxsim.getOptimizer().setMaxNumberOfIterations(maxiterations) waxsim.runTuning() @@ -575,8 +594,8 @@ def calcproperties(gascondensateFluid, inputDict): """ properties = jneqsim.util.generator.PropertyGenerator( gascondensateFluid, - JDouble[:](inputDict["temperature"]), - JDouble[:](inputDict["pressure"]), + _as_float_list(inputDict["temperature"]), + _as_float_list(inputDict["pressure"]), ) props = properties.calculate() calculatedProperties = {k: list(v) for k, v in props.items()} @@ -725,7 +744,7 @@ def separatortest(fluid, pressure, temperature, GOR=None, Bo=None, display=False length = len(pressure) sepSim = jneqsim.pvtsimulation.simulation.SeparatorTest(fluid) - sepSim.setSeparatorConditions(JDouble[:](temperature), JDouble[:](pressure)) + sepSim.setSeparatorConditions(_as_float_list(temperature), _as_float_list(pressure)) sepSim.runCalc() for i in range(0, length): GOR.append(sepSim.getGOR()[i]) @@ -774,7 +793,7 @@ def CVD( length = len(pressure) cvdSim = jneqsim.pvtsimulation.simulation.ConstantVolumeDepletion(fluid) - cvdSim.setPressures(JDouble[:](pressure)) + cvdSim.setPressures(_as_float_list(pressure)) cvdSim.setTemperature(temperature) cvdSim.runCalc() for i in range(0, length): @@ -818,7 +837,9 @@ def viscositysim( aqueousviscosity = [] length = len(pressure) cmeSim = jneqsim.pvtsimulation.simulation.ViscositySim(fluid) - cmeSim.setTemperaturesAndPressures(JDouble[:](temperature), JDouble[:](pressure)) + cmeSim.setTemperaturesAndPressures( + _as_float_list(temperature), _as_float_list(pressure) + ) cmeSim.runCalc() for i in range(0, length): gasviscosity.append(cmeSim.getGasViscosity()[i]) @@ -880,7 +901,9 @@ def CME( length = len(pressure) cvdSim = jneqsim.pvtsimulation.simulation.ConstantMassExpansion(fluid) - cvdSim.setTemperaturesAndPressures(JDouble[:](temperature), JDouble[:](pressure)) + cvdSim.setTemperaturesAndPressures( + _as_float_list(temperature), _as_float_list(pressure) + ) cvdSim.runCalc() saturationPressure = cvdSim.getSaturationPressure() for i in range(0, length): @@ -952,7 +975,7 @@ def difflib( length = len(pressure) cvdSim = jneqsim.pvtsimulation.simulation.DifferentialLiberation(fluid) - cvdSim.setPressures(JDouble[:](pressure)) + cvdSim.setPressures(_as_float_list(pressure)) cvdSim.setTemperature(temperature) cvdSim.runCalc() for i in range(0, length): @@ -991,7 +1014,9 @@ def GOR(fluid, pressure, temperature, GORdata=None, Bo=None, display=False): length = len(pressure) jGOR = jneqsim.pvtsimulation.simulation.GOR(fluid) - jGOR.setTemperaturesAndPressures(JDouble[:](temperature), JDouble[:](pressure)) + jGOR.setTemperaturesAndPressures( + _as_float_list(temperature), _as_float_list(pressure) + ) jGOR.runCalc() for i in range(0, length): GORdata.append(jGOR.getGOR()[i]) @@ -1034,7 +1059,7 @@ def swellingtest( cvdSim.setInjectionGas(fluid2) cvdSim.setTemperature(temperature) cvdSim.setCummulativeMolePercentGasInjected( - JDouble[:](cummulativeMolePercentGasInjected) + _as_float_list(cummulativeMolePercentGasInjected) ) cvdSim.runCalc() for i in range(0, length2): @@ -1856,7 +1881,7 @@ def fluidComposition(testSystem, composition): Returns: None """ - testSystem.setMolarComposition(JDouble[:](composition)) + testSystem.setMolarComposition(_as_float_list(composition)) testSystem.init(0) @@ -1871,7 +1896,7 @@ def fluidCompositionPlus(testSystem, composition): Returns: None """ - testSystem.setMolarCompositionPlus(JDouble[:](composition)) + testSystem.setMolarCompositionPlus(_as_float_list(composition)) testSystem.init(0) diff --git a/tests/process/test_ProcessTools.py b/tests/process/test_ProcessTools.py index 81e07e56..992949b4 100644 --- a/tests/process/test_ProcessTools.py +++ b/tests/process/test_ProcessTools.py @@ -23,7 +23,6 @@ ) from neqsim.thermo import TPflash, fluid, printFrame, fluid_df from pytest import approx -from jpype.types import JDouble from neqsim import jneqsim import pandas as pd import neqsim.standards @@ -149,7 +148,7 @@ def test_flowSplitter(): stream2 = stream("stre333", compressor_1.getOutStream()) streamSplit = splitter("split1", stream2) - streamSplit.setFlowRates(JDouble[:]([5.0, 0.1]), "MSm3/day") + streamSplit.setFlowRates([5.0, 0.1], "MSm3/day") resycStream1 = streamSplit.getSplitStream(1) @@ -166,17 +165,17 @@ def test_flowSplitter(): assert exportStream.getFlowRate("MSm3/day") == approx(5.0) assert streamresycl.getFlowRate("MSm3/day") == approx(0.1) - streamSplit.setFlowRates(JDouble[:]([5, 0.5]), "MSm3/day") + streamSplit.setFlowRates([5, 0.5], "MSm3/day") runProcess() assert exportStream.getFlowRate("MSm3/day") == approx(5.0) assert streamresycl.getFlowRate("MSm3/day") == approx(0.5) - streamSplit.setFlowRates(JDouble[:]([-1, 2.5]), "MSm3/day") + streamSplit.setFlowRates([-1, 2.5], "MSm3/day") runProcess() assert exportStream.getFlowRate("MSm3/day") == approx(5.0) assert streamresycl.getFlowRate("MSm3/day") == approx(2.5) - streamSplit.setSplitFactors(JDouble[:]([1.0, 0.0])) + streamSplit.setSplitFactors([1.0, 0.0]) runProcess() assert exportStream.getFlowRate("MSm3/day") == approx(5.0) assert streamresycl.getFlowRate("MSm3/day") == approx(0.0)