Skip to content

plan

tit.server.routes.plan

POST /api/plan/{kind} -- resolve what running a config would do.

For every kind this route builds the concrete output directory (or directories, for a batch of subjects/montages) via :class:~tit.paths.PathManager -- the UI never recomputes a path convention itself (design rule R1) -- reports whether it already exists, estimates the job's resource cost from :mod:tit.jobs.costs, and checks for held lock conflicts via :mod:tit.jobs.locks / :mod:tit.jobs.api (empty when the job manager, B1, is not wired up yet in this environment).

config uses the same kind -> dataclass resolution as :mod:tit.server.routes.validate (:func:~tit.server.routes.validate.cls_for) -- a body that fails validation here fails with the same ValueError/TypeError a POST /api/validate/{kind} call against it would report, just as a 422 instead of a 200 {ok: false} (a plan cannot be computed for a config that does not even deserialize).

Per-kind output-dir resolution

sim One :class:PlanJob per (subject, montage). config.montages applies to every subject in the plan; the request body's top-level montage_sources field (contracts/openapi.yaml's MontageSources -- {"flex": [{"subject"?, "run", "electrode_type"?, "eeg_net"?}], "freehand": [{"subject"?, "name"} | "name"]}) is resolved via :mod:tit.sim.montage_sources into extra montages attached only to their own subject (flex-search runs and freehand stim-configs are inherently subject-scoped resources, unlike a plain montage_list.json entry). For one release, the pre-contract convention of the same data nested under config["montage_sources"] (with run_name/subject_id field names) is still read as a fallback when the top-level field is absent -- see :func:_montage_sources_for_request. Either shape is ignored by :func:~tit.config_io.deserialize_config when read from config (not a SimulationConfig field). flex / flex_adaptive / flex_pareto config.output_folder when set, else a previewed :func:~tit.opt.flex.utils.generate_run_dirname name under flex-search/<subject>/ (the real run may pick a different timestamp if run later -- flagged as a warning). ex / mex config.run_name or a timestamp, under ex-search / m-ex-search. resolved.search_space gives the exact combination count from :mod:tit.opt.ex.logic / :mod:tit.opt.mex.logic (never materializing the search). leadfield Existing leadfield lookup via LeadfieldGenerator.list_leadfields (no SimNIBS import). analyzer config.output_dir when set, else :meth:PathManager.analysis_output_dir with the same arguments the Analyzer passes (field and tissue included; an atlas analysis named by its regions as requested). A group run plans each subject's own default folder (output_dir never reaches them) plus one project-level job (subject="") for the group summary folder: output_dir, or the timestamped group_analysis/<name>_<YYYYmmdd_HHMMSS> a run started now would name. pre :func:tit.jobs.plans.plan_preprocessing's G1-G6/report DAG, flattened to one :class:PlanJob per stage per subject (not one per subject) -- exactly the granularity POST /api/jobs/groups submits. Each stage's output_dir is a best-effort mapping to the directory that stage's flag writes to (see :func:_pre_stage_output_dir); its exists is :func:tit.pre.preflight.existing_outputs_for_step for that stage -- the output the run's skip/replace policy acts on -- not the directory's existence (G2a and G6 both touch m2m_<subject>/). Under "skip", a stage whose output an interrupted run left incomplete gets a warning naming the subject and the stage. The stages run one at a time (one job per product), so the ETA is their sum and the cost is one stage's. source forward mode: :meth:PathManager.forward per subject. fsavg_map mode: :meth:PathManager.sim_fsaverage per (subject, simulation) pair; it exists when that spacing's projection does, and is overwritten with fsavg_map.overwrite or when it lacks a requested field. stats Project-level (PlanJob.subject is ""): :meth:PathManager.stats_output with the literal analysis_type tit.stats.permutation itself uses ("group_comparison" / "correlation"). blender One :class:PlanJob whose output_dir is the directory the matching exporter will write into -- see :func:_blender_output_dir, which restates each mode's own _resolve_paths formula (the exporters cannot be imported here: they need bpy/trimesh, which exist only inside the SimNIBS container). nifti_average / nilearn One project-level :class:PlanJob (subject="", like stats): output_dir mirrors tit.stats.nifti_average.main / tit.plotting.nilearn.__main__.main's own formula exactly (derivatives/ti-toolbox/{nifti_average,nilearn_visuals}/<output_name or subdir_name>/). Real validation/planning since the runners lane registered NiftiAverageConfig/NilearnConfig in :data:tit.config_io.CONFIG_CLASS_REGISTRY -- NO_SCHEMA_KINDS (see :mod:tit.server.routes.validate) is empty as a result, kept only as an extension point.

PlanSystem

Bases: BaseModel

The machine an eta_minutes was computed for (:mod:tit.jobs.eta).

plan_jobs

plan_jobs(kind: str, body: PlanRequest) -> tuple[list[PlanJob], dict[str, Any] | None, list[str]] | None

The per-kind planners' answer for body: (jobs, resolved, warnings).

None when the kind has no config schema to plan from. Raises :class:HTTPException (404 unknown kind, 422 a config that does not deserialize or an output outside the project) exactly as POST /api/plan/{kind} answers. This is the folder resolution alone; :func:plan adds the cost estimate and the lock conflicts. The Jobs API reuses it to name a job's output folder (:func:tit.server.job_outputs.job_output_dir), so the two cannot disagree.

Source code in tit/server/routes/plan.py
def plan_jobs(
    kind: str, body: PlanRequest
) -> tuple[list[PlanJob], dict[str, Any] | None, list[str]] | None:
    """The per-kind planners' answer for *body*: ``(jobs, resolved, warnings)``.

    ``None`` when the kind has no config schema to plan from. Raises :class:`HTTPException`
    (404 unknown kind, 422 a config that does not deserialize or an output outside the
    project) exactly as ``POST /api/plan/{kind}`` answers. This is the folder resolution alone;
    :func:`plan` adds the cost estimate and the lock conflicts. The Jobs API reuses it to name a
    job's output folder (:func:`tit.server.job_outputs.job_output_dir`), so the two cannot
    disagree.
    """
    from tit.config_io import deserialize_config

    if kind not in ALL_KINDS:
        raise HTTPException(status_code=404, detail=f"unknown kind: {kind}")
    if body.created_by == "agent":
        body.config = with_app_defaults(kind, body.config)

    warnings: list[str] = []

    try:
        cls = cls_for(kind, body.config)
    except KindNotConfigurable as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc

    if cls is None:
        return None

    config_dict = body.config
    if kind == "pre":
        from tit.pre.config import migrate_legacy_keys

        config_dict = migrate_legacy_keys(config_dict, warnings=warnings)

    from tit.jobs.config_check import runner_view

    try:
        config = deserialize_config(cls, runner_view(kind, config_dict))
        if kind in {"ex", "mex", "flex", "flex_adaptive", "flex_pareto"}:
            from tit.opt.masks import validate_mask_paths

            validate_mask_paths(config)
        elif kind == "analyzer":
            from tit.analyzer.masks import validate_mask_path

            validate_mask_path(config)
    except (ValueError, TypeError, KeyError) as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc

    pm = get_path_manager()
    subject_ids = list(body.subject_ids or [])

    if kind == "pre":
        jobs, resolved = _plan_pre(pm, config, subject_ids, warnings)
    elif kind == "sim":
        jobs, resolved = _plan_sim(
            kind,
            pm,
            config,
            body.config,
            subject_ids,
            warnings,
            montage_sources_field=body.montage_sources,
        )
    elif kind in ("flex", "flex_adaptive", "flex_pareto"):
        jobs, resolved = _plan_flex(kind, pm, config, subject_ids, warnings)
    elif kind == "ex":
        jobs, resolved = _plan_ex(kind, pm, config, subject_ids, warnings)
    elif kind == "mex":
        jobs, resolved = _plan_mex(kind, pm, config, subject_ids, warnings)
    elif kind == "leadfield":
        jobs, resolved = _plan_leadfield(kind, pm, config, subject_ids, warnings)
    elif kind == "analyzer":
        jobs, resolved = _plan_analyzer(kind, pm, config, subject_ids, warnings)
    elif kind == "source":
        jobs, resolved = _plan_source(kind, pm, config, subject_ids, warnings)
    elif kind == "stats":
        jobs, resolved = _plan_stats(kind, pm, config)
    elif kind == "blender":
        jobs, resolved = _plan_blender(kind, pm, config, warnings)
    elif kind == "nifti_average":
        jobs, resolved = _plan_nifti_average(kind, pm, config)
    elif kind == "nilearn":
        jobs, resolved = _plan_nilearn(kind, pm, config)
    else:  # pragma: no cover - ALL_KINDS/NO_SCHEMA_KINDS covers everything else
        jobs, resolved = [], None

    _require_outputs_inside_project(pm, jobs)
    return jobs, resolved, warnings