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 @@

{{ rota.description }}

Period: {{ rota.start_date }} → {{ rota.end_date }}

+
+

Solver settings

+
+
Solver
+
{% if solver %}{{ solver }}{% else %}(default){% endif %}
+
Ratio
+
{% if solver_ratio is not None %}{{ solver_ratio }}{% else %}{% endif %}
+
+ {% if solver_options %} +
+

Solver options

+
{{ solver_options }}
+
+ {% endif %} +
+

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, }, )