Skip to content

Commit 474f79b

Browse files
Add OetcSettings.from_env() and forward solver options through Model.solve() (#612)
1 parent 6383e2a commit 474f79b

6 files changed

Lines changed: 535 additions & 40 deletions

File tree

doc/release_notes.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ Upcoming Version
1818
* Add the `sphinx-copybutton` to the documentation
1919
* Add SOS1 and SOS2 reformulations for solvers not supporting them.
2020
* Add semi-continous variables for solvers that support them
21+
* Add ``OetcSettings.from_env()`` classmethod to create OETC settings from environment variables (``OETC_EMAIL``, ``OETC_PASSWORD``, ``OETC_NAME``, ``OETC_AUTH_URL``, ``OETC_ORCHESTRATOR_URL``, ``OETC_CPU_CORES``, ``OETC_DISK_SPACE_GB``, ``OETC_DELETE_WORKER_ON_ERROR``).
22+
* Forward ``solver_name`` and ``**solver_options`` from ``Model.solve()`` to OETC handler. Call-level options override settings-level defaults.
2123
* Improve handling of CPLEX solver quality attributes to ensure metrics such are extracted correctly when available.
2224
* Fix Xpress IIS label mapping for masked constraints and add a regression test for matching infeasible coordinates.
2325
* Enable quadratic problems with SCIP on windows.

examples/solve-on-oetc.ipynb

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,12 @@
8686
"source": [
8787
"## Configure OETC Settings\n",
8888
"\n",
89-
"Next, we need to configure the OETC settings including credentials and compute requirements:"
89+
"There are two ways to configure OETC settings:\n",
90+
"\n",
91+
"1. **Manual construction** \u2014 build `OetcCredentials` and `OetcSettings` explicitly\n",
92+
"2. **`OetcSettings.from_env()`** \u2014 resolve credentials and options from environment variables\n",
93+
"\n",
94+
"### Option 1: Manual Construction"
9095
]
9196
},
9297
{
@@ -130,6 +135,48 @@
130135
"print(f\"Disk space: {settings.disk_space_gb} GB\")"
131136
]
132137
},
138+
{
139+
"cell_type": "markdown",
140+
"metadata": {},
141+
"source": [
142+
"### Option 2: Create Settings from Environment Variables\n",
143+
"\n",
144+
"`OetcSettings.from_env()` reads configuration from environment variables,\n",
145+
"with optional keyword overrides. This is the recommended approach for\n",
146+
"CI/CD pipelines and production deployments.\n",
147+
"\n",
148+
"| Environment Variable | Required | Description |\n",
149+
"|---|---|---|\n",
150+
"| `OETC_EMAIL` | Yes | Account email |\n",
151+
"| `OETC_PASSWORD` | Yes | Account password |\n",
152+
"| `OETC_NAME` | Yes | Job name |\n",
153+
"| `OETC_AUTH_URL` | Yes | Authentication server URL |\n",
154+
"| `OETC_ORCHESTRATOR_URL` | Yes | Orchestrator server URL |\n",
155+
"| `OETC_CPU_CORES` | No | CPU cores (default: 2) |\n",
156+
"| `OETC_DISK_SPACE_GB` | No | Disk space in GB (default: 10) |\n",
157+
"| `OETC_DELETE_WORKER_ON_ERROR` | No | Delete worker on error (default: false) |\n",
158+
"\n",
159+
"Keyword arguments take precedence over environment variables."
160+
]
161+
},
162+
{
163+
"cell_type": "code",
164+
"metadata": {},
165+
"outputs": [],
166+
"source": [
167+
"# Create settings from environment variables\n",
168+
"# All required env vars must be set: OETC_EMAIL, OETC_PASSWORD,\n",
169+
"# OETC_NAME, OETC_AUTH_URL, OETC_ORCHESTRATOR_URL\n",
170+
"settings = OetcSettings.from_env()\n",
171+
"\n",
172+
"# Or override specific values via keyword arguments\n",
173+
"settings = OetcSettings.from_env(\n",
174+
" cpu_cores=8,\n",
175+
" disk_space_gb=50,\n",
176+
")"
177+
],
178+
"execution_count": null
179+
},
133180
{
134181
"cell_type": "markdown",
135182
"metadata": {},
@@ -228,38 +275,49 @@
228275
"\n",
229276
"### Solver Options\n",
230277
"\n",
231-
"You can pass solver-specific options through the `solver_options` parameter:"
278+
"Solver name and options can be configured at two levels:\n",
279+
"\n",
280+
"1. **Settings level** \u2014 defaults stored in `OetcSettings.solver` and `OetcSettings.solver_options`\n",
281+
"2. **Call level** \u2014 passed via `m.solve(solver_name=..., **solver_options)`\n",
282+
"\n",
283+
"Call-level options **override** settings-level options. The two dicts are\n",
284+
"merged (call-time takes precedence), and the original settings are never\n",
285+
"mutated."
232286
]
233287
},
234288
{
235289
"cell_type": "code",
236-
"execution_count": null,
237290
"metadata": {},
238291
"outputs": [],
239292
"source": [
240-
"# Example with advanced solver options\n",
293+
"# Settings-level defaults\n",
241294
"advanced_settings = OetcSettings(\n",
242295
" credentials=credentials,\n",
243296
" name=\"advanced-linopy-job\",\n",
244297
" authentication_server_url=\"https://auth.oetcloud.com\",\n",
245298
" orchestrator_server_url=\"https://orchestrator.oetcloud.com\",\n",
246-
" solver=\"gurobi\", # Using Gurobi solver\n",
299+
" solver=\"gurobi\",\n",
247300
" solver_options={\n",
248-
" \"TimeLimit\": 600, # 10 minutes\n",
249-
" \"MIPGap\": 0.01, # 1% optimality gap\n",
250-
" \"Threads\": 4, # Use 4 threads\n",
251-
" \"OutputFlag\": 1, # Enable solver output\n",
301+
" \"TimeLimit\": 600,\n",
302+
" \"MIPGap\": 0.01,\n",
252303
" },\n",
253-
" cpu_cores=8, # More CPU cores for larger problems\n",
254-
" disk_space_gb=50, # More disk space\n",
304+
" cpu_cores=8,\n",
305+
" disk_space_gb=50,\n",
255306
")\n",
256307
"\n",
257-
"print(\"Advanced OETC settings:\")\n",
258-
"print(f\"Solver: {advanced_settings.solver}\")\n",
259-
"print(f\"Solver options: {advanced_settings.solver_options}\")\n",
260-
"print(f\"CPU cores: {advanced_settings.cpu_cores}\")\n",
261-
"print(f\"Disk space: {advanced_settings.disk_space_gb} GB\")"
262-
]
308+
"advanced_handler = OetcHandler(advanced_settings)\n",
309+
"\n",
310+
"# Call-level overrides: solver_name and solver_options are forwarded\n",
311+
"# to OETC and merged with the settings defaults.\n",
312+
"# Here MIPGap from settings (0.01) is kept, TimeLimit is overridden to 300.\n",
313+
"status, condition = m.solve(\n",
314+
" remote=advanced_handler,\n",
315+
" solver_name=\"gurobi\",\n",
316+
" TimeLimit=300,\n",
317+
" Threads=4,\n",
318+
")"
319+
],
320+
"execution_count": null
263321
},
264322
{
265323
"cell_type": "markdown",

linopy/model.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,7 +1401,9 @@ def solve(
14011401

14021402
if remote is not None:
14031403
if isinstance(remote, OetcHandler):
1404-
solved = remote.solve_on_oetc(self)
1404+
solved = remote.solve_on_oetc(
1405+
self, solver_name=solver_name, **solver_options
1406+
)
14051407
else:
14061408
solved = remote.solve_on_remote(
14071409
self,
@@ -1417,7 +1419,8 @@ def solve(
14171419
**solver_options,
14181420
)
14191421

1420-
self.objective.set_value(solved.objective.value)
1422+
if solved.objective.value is not None:
1423+
self.objective.set_value(float(solved.objective.value))
14211424
self.status = solved.status
14221425
self.termination_condition = solved.termination_condition
14231426
for k, v in self.variables.items():

linopy/remote/oetc.py

Lines changed: 115 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import base64
24
import gzip
35
import json
@@ -8,6 +10,10 @@
810
from dataclasses import dataclass, field
911
from datetime import datetime, timedelta
1012
from enum import Enum
13+
from typing import TYPE_CHECKING, Any
14+
15+
if TYPE_CHECKING:
16+
from linopy.model import Model
1117

1218
try:
1319
import requests
@@ -42,11 +48,97 @@ class OetcSettings:
4248
orchestrator_server_url: str
4349
compute_provider: ComputeProvider = ComputeProvider.GCP
4450
solver: str = "highs"
45-
solver_options: dict = field(default_factory=dict)
51+
solver_options: dict[str, Any] = field(default_factory=dict)
4652
cpu_cores: int = 2
4753
disk_space_gb: int = 10
4854
delete_worker_on_error: bool = False
4955

56+
@classmethod
57+
def from_env(
58+
cls,
59+
*,
60+
email: str | None = None,
61+
password: str | None = None,
62+
name: str | None = None,
63+
authentication_server_url: str | None = None,
64+
orchestrator_server_url: str | None = None,
65+
cpu_cores: int | None = None,
66+
disk_space_gb: int | None = None,
67+
delete_worker_on_error: bool | None = None,
68+
) -> OetcSettings:
69+
required_fields = {
70+
"email": ("OETC_EMAIL", email),
71+
"password": ("OETC_PASSWORD", password),
72+
"name": ("OETC_NAME", name),
73+
"authentication_server_url": ("OETC_AUTH_URL", authentication_server_url),
74+
"orchestrator_server_url": (
75+
"OETC_ORCHESTRATOR_URL",
76+
orchestrator_server_url,
77+
),
78+
}
79+
80+
resolved: dict[str, Any] = {}
81+
missing: list[str] = []
82+
83+
for field_name, (env_var, kwarg) in required_fields.items():
84+
if kwarg is not None:
85+
resolved[field_name] = kwarg
86+
else:
87+
env_val = os.environ.get(env_var, "").strip()
88+
if env_val:
89+
resolved[field_name] = env_val
90+
else:
91+
missing.append(env_var)
92+
93+
if missing:
94+
raise ValueError(
95+
f"Missing required OETC configuration: {', '.join(missing)}"
96+
)
97+
98+
kwargs: dict[str, Any] = {
99+
"credentials": OetcCredentials(
100+
email=resolved["email"], password=resolved["password"]
101+
),
102+
"name": resolved["name"],
103+
"authentication_server_url": resolved["authentication_server_url"],
104+
"orchestrator_server_url": resolved["orchestrator_server_url"],
105+
}
106+
107+
if cpu_cores is not None:
108+
kwargs["cpu_cores"] = cpu_cores
109+
elif (cpu_env := os.environ.get("OETC_CPU_CORES")) is not None:
110+
try:
111+
kwargs["cpu_cores"] = int(cpu_env)
112+
except ValueError as e:
113+
raise ValueError(
114+
f"OETC_CPU_CORES is not a valid integer: {cpu_env}"
115+
) from e
116+
117+
if disk_space_gb is not None:
118+
kwargs["disk_space_gb"] = disk_space_gb
119+
elif (disk_env := os.environ.get("OETC_DISK_SPACE_GB")) is not None:
120+
try:
121+
kwargs["disk_space_gb"] = int(disk_env)
122+
except ValueError as e:
123+
raise ValueError(
124+
f"OETC_DISK_SPACE_GB is not a valid integer: {disk_env}"
125+
) from e
126+
127+
if delete_worker_on_error is not None:
128+
kwargs["delete_worker_on_error"] = delete_worker_on_error
129+
elif (del_env := os.environ.get("OETC_DELETE_WORKER_ON_ERROR")) is not None:
130+
low = del_env.lower()
131+
if low in ("true", "1", "yes"):
132+
kwargs["delete_worker_on_error"] = True
133+
elif low in ("false", "0", "no"):
134+
kwargs["delete_worker_on_error"] = False
135+
else:
136+
raise ValueError(
137+
f"OETC_DELETE_WORKER_ON_ERROR has invalid value: {del_env}"
138+
)
139+
140+
return cls(**kwargs)
141+
50142

51143
@dataclass
52144
class GcpCredentials:
@@ -226,12 +318,16 @@ def __get_gcp_credentials(self) -> GcpCredentials:
226318
except Exception as e:
227319
raise Exception(f"Error fetching GCP credentials: {e}")
228320

229-
def _submit_job_to_compute_service(self, input_file_name: str) -> str:
321+
def _submit_job_to_compute_service(
322+
self, input_file_name: str, solver: str, solver_options: dict[str, Any]
323+
) -> str:
230324
"""
231325
Submit a job to the compute service.
232326
233327
Args:
234328
input_file_name: Name of the input file uploaded to GCP
329+
solver: Solver name to use
330+
solver_options: Solver options dict
235331
236332
Returns:
237333
CreateComputeJobResult: The job creation result with UUID
@@ -243,8 +339,8 @@ def _submit_job_to_compute_service(self, input_file_name: str) -> str:
243339
logger.info("OETC - Submitting compute job...")
244340
payload = {
245341
"name": self.settings.name,
246-
"solver": self.settings.solver,
247-
"solver_options": self.settings.solver_options,
342+
"solver": solver,
343+
"solver_options": solver_options,
248344
"provider": self.settings.compute_provider.value,
249345
"cpu_cores": self.settings.cpu_cores,
250346
"disk_space_gb": self.settings.disk_space_gb,
@@ -534,13 +630,19 @@ def _download_file_from_gcp(self, file_name: str) -> str:
534630
except Exception as e:
535631
raise Exception(f"Failed to download file from GCP: {e}")
536632

537-
def solve_on_oetc(self, model): # type: ignore
633+
def solve_on_oetc(
634+
self, model: Model, solver_name: str | None = None, **solver_options: Any
635+
) -> Model:
538636
"""
539637
Solve a linopy model on the OET Cloud compute app.
540638
541639
Parameters
542640
----------
543641
model : linopy.model.Model
642+
solver_name : str, optional
643+
Override the solver from settings.
644+
**solver_options
645+
Override/extend solver_options from settings.
544646
545647
Returns
546648
-------
@@ -552,17 +654,19 @@ def solve_on_oetc(self, model): # type: ignore
552654
Exception: If solving fails at any stage
553655
"""
554656
try:
555-
# Save model to temporary file and upload
657+
effective_solver = solver_name or self.settings.solver
658+
merged_solver_options = {**self.settings.solver_options, **solver_options}
659+
556660
with tempfile.NamedTemporaryFile(prefix="linopy-", suffix=".nc") as fn:
557661
fn.file.close()
558662
model.to_netcdf(fn.name)
559663
input_file_name = self._upload_file_to_gcp(fn.name)
560664

561-
# Submit job and wait for completion
562-
job_uuid = self._submit_job_to_compute_service(input_file_name)
665+
job_uuid = self._submit_job_to_compute_service(
666+
input_file_name, effective_solver, merged_solver_options
667+
)
563668
job_result = self.wait_and_get_job_data(job_uuid)
564669

565-
# Download and load the solution
566670
if not job_result.output_files:
567671
raise Exception("No output files found in completed job")
568672

@@ -572,26 +676,22 @@ def solve_on_oetc(self, model): # type: ignore
572676

573677
solution_file_path = self._download_file_from_gcp(output_file_name)
574678

575-
# Load the solved model
576679
solved_model = linopy.read_netcdf(solution_file_path)
577680

578-
# Clean up downloaded file
579681
os.remove(solution_file_path)
580682

581683
logger.info(
582684
f"OETC - Model solved successfully. Status: {solved_model.status}"
583685
)
584-
if hasattr(solved_model, "objective") and hasattr(
585-
solved_model.objective, "value"
586-
):
686+
if solved_model.objective.value is not None:
587687
logger.info(
588688
f"OETC - Objective value: {solved_model.objective.value:.2e}"
589689
)
590690

591691
return solved_model
592692

593693
except Exception as e:
594-
raise Exception(f"Error solving model on OETC: {e}")
694+
raise Exception(f"Error solving model on OETC: {e}") from e
595695

596696
def _gzip_compress(self, source_path: str) -> str:
597697
"""

0 commit comments

Comments
 (0)