diff --git a/djangorota/djangorota/templates/rota/rota_detail.html b/djangorota/djangorota/templates/rota/rota_detail.html
index 77a8cbf..27002a6 100644
--- a/djangorota/djangorota/templates/rota/rota_detail.html
+++ b/djangorota/djangorota/templates/rota/rota_detail.html
@@ -11,6 +11,22 @@
RotaBuilder summary
diff --git a/djangorota/rota/forms.py b/djangorota/rota/forms.py
index c283aae..146234c 100644
--- a/djangorota/rota/forms.py
+++ b/djangorota/rota/forms.py
@@ -415,6 +415,35 @@ class RotaScheduleForm(forms.ModelForm):
else:
self.fields[field_name] = forms.CharField(required=False, initial=initial, label=k.replace("_", " "), help_text=meta.get("help"))
+ # Expose solver selection and solver options (JSON) so users can tune
+ # solver behaviour per-rota. Persisted into `RotaSchedule.options` as
+ # keys `solver` (string) and `solver_options` (dict).
+ solver_initial = existing_opts.get("solver", "appsi_highs")
+ solver_opts_initial = existing_opts.get("solver_options", {})
+ try:
+ solver_opts_str = json.dumps(solver_opts_initial, indent=2)
+ except Exception:
+ solver_opts_str = ""
+
+ self.fields["solver"] = forms.CharField(required=False, initial=solver_initial, label="Solver", help_text="Solver plugin name to use (e.g. appsi_highs, scip, gurobi)")
+ # Common solver ratio is surfaced as a dedicated field for convenience
+ # (many workflows tune this). It's stored inside the solver_options
+ # dict under the key 'ratio'. The free-form JSON textarea remains for
+ # advanced options.
+ solver_ratio_initial = None
+ try:
+ if isinstance(solver_opts_initial, dict) and "ratio" in solver_opts_initial:
+ solver_ratio_initial = solver_opts_initial.get("ratio")
+ else:
+ # also accept legacy top-level 'ratio' key in existing_opts
+ solver_ratio_initial = existing_opts.get("ratio")
+ except Exception:
+ solver_ratio_initial = None
+
+ self.fields["solver"] = forms.CharField(required=False, initial=solver_initial, label="Solver", help_text="Solver plugin name to use (e.g. appsi_highs, scip, gurobi)")
+ self.fields["solver_ratio"] = forms.FloatField(required=False, initial=solver_ratio_initial, label="Solver ratio (relative gap)", help_text="Relative MIP gap / ratio (e.g. 0.01 for 1%)")
+ self.fields["solver_options"] = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 6}), initial=solver_opts_str, label="Solver options (JSON)", help_text="JSON object of solver-specific options; e.g. {\"seconds\": 60, \"threads\": 4}")
+
def clean_start_date(self):
start = self.cleaned_data.get("start_date")
if start is None:
@@ -506,6 +535,49 @@ class RotaScheduleForm(forms.ModelForm):
except Exception:
pass
+ # Persist solver selection and options (if provided). Solver options are
+ # stored as a dict under opts['solver_options']. If the user supplied
+ # a JSON string, attempt to parse it; otherwise keep raw value.
+ try:
+ solver_name = self.cleaned_data.get("solver")
+ if solver_name:
+ opts["solver"] = solver_name
+ except Exception:
+ pass
+
+ try:
+ so = self.cleaned_data.get("solver_options")
+ if so is not None and so != "":
+ if isinstance(so, str):
+ try:
+ parsed = json.loads(so)
+ except Exception:
+ # keep as raw string if parsing fails (non-fatal)
+ parsed = so
+ else:
+ parsed = so
+ opts["solver_options"] = parsed
+ except Exception:
+ pass
+
+ # If a dedicated solver_ratio field was provided, inject/override it
+ # into the solver_options dict so it is honoured at runtime. This
+ # ensures the friendly ratio field wins over any value in the JSON
+ # textarea.
+ try:
+ ratio_val = self.cleaned_data.get("solver_ratio")
+ if ratio_val is not None and ratio_val != "":
+ so = opts.get("solver_options")
+ if so is None or not isinstance(so, dict):
+ so = {}
+ try:
+ so["ratio"] = float(ratio_val)
+ except Exception:
+ so["ratio"] = ratio_val
+ opts["solver_options"] = so
+ except Exception:
+ pass
+
instance.options = opts
if commit:
diff --git a/djangorota/rota/services.py b/djangorota/rota/services.py
index f534d34..aa21351 100644
--- a/djangorota/rota/services.py
+++ b/djangorota/rota/services.py
@@ -90,8 +90,21 @@ def _run_rota_job(run_id: int, use_external_generator: bool = False, generate_bu
err_buf = io.StringIO()
with contextlib.redirect_stdout(out_buf), contextlib.redirect_stderr(err_buf):
rb = rota.to_rota_builder()
- # build the model and optionally solve
- rb.build_and_solve(solve=builder_solve)
+ # If the rota has solver options saved in rota.options, prefer
+ # those when invoking the builder. `solver_options` is expected
+ # to be a dict of solver-specific options; `solver` may be a
+ # string naming the solver plugin.
+ opts = rota.options or {}
+ solver_options = opts.get("solver_options") or {}
+ solver_name = opts.get("solver") or None
+
+ # build the model and optionally solve. If solver_name is
+ # provided, pass it through; otherwise let builder default.
+ if solver_name:
+ rb.build_and_solve(options=solver_options, solve=builder_solve, solver=solver_name)
+ else:
+ rb.build_and_solve(options=solver_options, solve=builder_solve)
+
html = rb.get_worker_timetable_html(include_html_tag=True)
# store under result so it's accessible from UI
diff --git a/djangorota/rota/views.py b/djangorota/rota/views.py
index 9b7a0f2..0b895e5 100644
--- a/djangorota/rota/views.py
+++ b/djangorota/rota/views.py
@@ -216,6 +216,40 @@ def rota_detail(request, rota_id):
except Exception:
worker_count = None
+ # Extract solver settings saved on the rota (if any) for display.
+ try:
+ opts = rota.options or {}
+ solver = opts.get("solver")
+ so = opts.get("solver_options")
+ # Normalize solver_options to a dict when possible and extract ratio
+ solver_options = None
+ solver_ratio = None
+ if isinstance(so, dict):
+ solver_options = so
+ solver_ratio = so.get("ratio")
+ elif isinstance(so, str):
+ try:
+ solver_options = json.loads(so)
+ except Exception:
+ solver_options = so
+ if isinstance(solver_options, dict):
+ solver_ratio = solver_options.get("ratio")
+ # fallback to legacy top-level 'ratio' key
+ if solver_ratio is None:
+ solver_ratio = opts.get("ratio")
+ # Pretty JSON for display when dict
+ try:
+ if isinstance(solver_options, dict):
+ solver_options_pretty = json.dumps(solver_options, indent=2)
+ else:
+ solver_options_pretty = str(solver_options) if solver_options is not None else None
+ except Exception:
+ solver_options_pretty = None
+ except Exception:
+ solver = None
+ solver_options_pretty = None
+ solver_ratio = None
+
# legacy RotaOptionsForm not used here; we provide a typed RotaScheduleForm below
# Provide a typed RotaScheduleForm instance so the template can render
@@ -238,6 +272,9 @@ def rota_detail(request, rota_id):
"weeks_to_rota": weeks_to_rota,
"shift_count": shift_count,
"worker_count": worker_count,
+ "solver": solver,
+ "solver_options": solver_options_pretty,
+ "solver_ratio": solver_ratio,
},
)