Add solver settings display and configuration options to rota detail view

This commit is contained in:
Ross
2025-12-17 17:58:32 +00:00
parent 023e345591
commit dadf035627
4 changed files with 140 additions and 2 deletions
@@ -11,6 +11,22 @@
<p class="subtitle">{{ rota.description }}</p>
<p>Period: {{ rota.start_date }} → {{ rota.end_date }}</p>
<div class="box" style="margin-top:0.75rem;">
<h2 class="subtitle is-6">Solver settings</h2>
<div class="columns is-mobile is-multiline" style="margin-bottom:0.5rem;">
<div class="column is-narrow"><strong>Solver</strong></div>
<div class="column">{% if solver %}{{ solver }}{% else %}<span class="has-text-grey">(default)</span>{% endif %}</div>
<div class="column is-narrow"><strong>Ratio</strong></div>
<div class="column">{% if solver_ratio is not None %}{{ solver_ratio }}{% else %}<span class="has-text-grey"></span>{% endif %}</div>
</div>
{% if solver_options %}
<div style="margin-top:0.5rem;">
<h3 class="is-size-7">Solver options</h3>
<pre style="white-space:pre-wrap; margin:0;">{{ solver_options }}</pre>
</div>
{% endif %}
</div>
<div class="box" style="margin-top:0.75rem;">
<h2 class="subtitle is-6">RotaBuilder summary</h2>
<div class="columns is-mobile is-multiline" style="margin-bottom:0.5rem;">
+72
View File
@@ -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:
+15 -2
View File
@@ -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
+37
View File
@@ -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,
},
)