Add solver settings display and configuration options to rota detail view
This commit is contained in:
@@ -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;">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user