======QtiSAS Fittable Python API======

Python scripting interface for the **Fittable** fitting widget.
Access via: ''f = Fittable''

**You cannot define or create fitting functions via Python.**
''configure()'' and ''setFunction()'' select a function **by name** from the pre-compiled Fittable library.
Passing a formula string such as ''"a*x+b"'' will raise ''ValueError''.
Use ''f.fitFunctions()'' to list all available function names.
Function authoring via Python will be supported in a future release through the **Compiler** interface.

**Boolean aliases:** QtiSAS defines ''true'' and ''false'' as aliases for ''True'' and ''False''.
Both forms are accepted in scripts: ''f.setInstrumentalFit(true)'' and ''f.setInstrumentalFit(True)'' are equivalent.

----

=====Quick Start=====

====Minimal single-dataset fit====

<code python>
f = Fittable

f.configure("Guinier", ["table_I"])
chi2 = f.fit()

r = f.results()
print(r["Rg"])      # (value, error)
</code>

----

====Multi-dataset global fit====

<code python>
f = Fittable

f.configure("Guinier", ["table_I1", "table_I2", "table_I3"])

f.setParamShared("Rg", True)
f.setParamLimits("Rg", 1.0, 500.0)
f.setFitMethod("LM")

chi2 = f.fit()
print(f.results())
print(f.fitReport())
</code>

----

=====Ordering Rules=====

The API has two phases separated by function selection:

  - **Pre-function** — dataset count, fit algorithm options
  - **Function selection** — ''configure()'' or ''setFunction()''
  - **Post-function** — datasets, range, weighting, resolution, polydispersity, parameters

''setInstrumentalFit()'' and ''setInstrument()'' may be called at any stage (before or after function selection). When called at stage ''"fit"'' or later, the UI widgets and parameter tables are updated immediately.

----

=====Stage Navigation=====

The widget has five stages reflecting the current UI page:

^ Stage ^ Meaning ^
| ''"init"'' | no function loaded (function-selection page) |
| ''"fit"'' | function and datasets configured (fitting page) |
| ''"simulate"'' | results page, Simulate Curve tab active |
| ''"results"'' | results page, Generate Tables tab active |
| ''"batch-fit"'' | results page, Batch-Fit tab active |

----

====stage()====

<code python>
print(f.stage())    # e.g. "fit"
</code>

----

====selectStage(s)====

Navigates to the named stage using the widget's own page-transition logic.

<code python>
f.selectStage("fit")          # go to fitting page
f.selectStage("simulate")     # go to Simulate Curve tab
f.selectStage("results")      # go to Generate Tables tab
f.selectStage("batch-fit")    # go to Batch-Fit tab
f.selectStage("init")         # go back to function-selection page
</code>

  * Returns ''True'' on success, ''False'' if already in ''init'' and ''s == "init"''.
  * Raises ''RuntimeError'' if called from ''init'' stage with any target other than ''"init"''.
  * Raises ''ValueError'' for unknown stage names.

----

=====State Introspection=====

====state()====

<code python>
s = f.state()
</code>

Returns a dict:

^ Key ^ Type ^ Description ^
| ''stage'' | str | ''"init"'' / ''"ready"'' / ''"fitted"'' — NOT the same 5-value vocabulary ''stage()''/''selectStage()'' use. This reflects only whether a function is loaded and whether a fit has run — it does NOT track which tab (simulate/results/batch-fit) is active. After ''f.selectStage("simulate")'', ''f.stage()'' returns ''"simulate"'' but ''f.state()["stage"]'' still returns ''"ready"''/''"fitted"''. |
| ''function'' | str | current function name (empty if not set) |
| ''datasets'' | int | number of configured datasets |
| ''selected'' | list[str] | names of selected dataset columns — NOT ''selectedDatasets''; that key does not exist and raises ''KeyError'' |
| ''fitMethod'' | int | 0=Simplex, 1=LM, 2=GenMin |
| ''params'' | int | number of parameters |
| ''instrumental'' | bool | SANS resolution/polydispersity active |
| ''chi2'' | float or None | last Chi²/DoF, or ''None'' if not yet fitted |
| ''dof'' | float or None | last degrees of freedom, or ''None'' if not yet fitted |

----

=====Function Discovery=====

====fitFunctions(pattern="")====

<code python>
f.fitFunctions()             # all available functions
f.fitFunctions("*sphere*")   # wildcard filter (case-insensitive)
f.fitFunctions("Guinier*")   # prefix match
f.fitFunctions("*SANS*")     # substring match
</code>

Returns sorted list of function names. Wildcards: ''*'' matches any substring including ''/''.

**''Fittable'' has ''fitFunctions()''; it does NOT have ''functionNames()''** — that name belongs to ''Compiler'', a completely different singleton (see compile-python-api.txt). Don't borrow either singleton's method name onto the other — ''Compiler'' needs ''c.functionNames("ALL")'' to list/check compiled functions, never ''c.fitFunctions(...)''.

----

====currentFunction()====

<code python>
name = f.currentFunction()
</code>

----

====Path — compiled function library directory====

<code python>
lib_dir = f.Path   # e.g. "~/.config/qtisas/FitFunctions/" — readable/writable attribute, not a method
</code>

----

=====Setup=====

====configure(function, datasets=[]) — recommended====

Resets to ''init'' stage, then runs the full mandatory sequence
''setDatasetCount → setFunction → selectDataset'' in one call.

''function'' must be the **name** of a function already in the Fittable library — **not a formula string**.
Use ''f.fitFunctions()'' to see all available names.

<code python>
f.configure("Guinier")                              # single dataset (select manually after)
f.configure("Guinier", ["table_I"])                 # "table_I" = column "I" in table "table"
f.configure("Guinier", ["table_I1", "table_I2"])    # multi-dataset global fit
# datasets are Y-column names ("tablename_colname"), NOT table names — use f.datasets() to list them
</code>

  * Raises ''ValueError'' if the function or any dataset name is not found.
  * Safe to call from any stage — always starts fresh.

**IMPORTANT: each item in ''datasets'' is one COMPLETE ''"tablename_colname"'' string — a 2-item
list means a 2-DATASET GLOBAL FIT, not "table name + column name" split apart.** Splitting a
single table+column pair into two list items asks for two entirely separate datasets, each of
which must independently exist:
  * WRONG: ''f.configure("Guinier", ["data", "I"])'' — this requests dataset ''"data"'' AND
    dataset ''"I"'' as two separate fits (neither of which exists — ''"I"'' alone is never a
    valid dataset name) — raises ''ValueError'' on the first missing one, not a hint that the
    split was wrong.
  * RIGHT: ''f.configure("Guinier", ["data_I"])'' — one combined string, column ''I'' of table
    ''data''.
  * RIGHT (genuinely two datasets): ''f.configure("Guinier", ["data1_I", "data2_I"])'' — a global
    fit across two different tables' ''I'' columns, each a complete ''tablename_colname'' string.

----

====Manual setup (alternative to configure)====

<code python>
f.setDatasetCount(3)            # MUST come before setFunction
f.setFunction("Guinier")        # raises ValueError if not found
f.selectDataset(1, "table_I1")  # 1-indexed; raises ValueError if not found
f.selectDataset(2, "table_I2")
f.selectDataset(3, "table_I3")
</code>

----

=====Datasets=====

**Dataset names are Y-column names, not table names.**
A column name has the form ''"tablename_colname"'' (e.g. ''"data_y"'' for column ''y'' in table ''data'').
Pass a table name such as ''"data"'' and ''configure()'' will raise ''ValueError''.
Use ''f.datasets()'' to list all available Y-column names, or ''t.colNames()'' on the table.

^ Function ^ Description ^
| ''datasets()'' | list of all Y-column names available in the workspace (form: ''"tablename_colname"'') |
| ''selectedDatasets()'' | list of currently selected dataset names |
| ''selectedDataset(m)'' | name of dataset m (1-indexed) |
| ''datasetCount()'' | number of configured datasets |
| ''setDatasetCount(n)'' | set number (call before ''setFunction'') |
| ''selectDataset(m, name)'' | assign dataset to slot m (call after ''setFunction'') |

**''datasets()'' is read-only — it lists what's already configured, it cannot register new ones.** Calling it with an argument (''f.datasets(["name"])'', intending to "add" a dataset) raises ''TypeError: datasets(self): too many arguments''; calling it bare afterward (''f.datasets()'') then compiles fine but does nothing useful — the return value is discarded, and the fit still has zero datasets configured. To actually set datasets, call ''configure(function, [...])'' again with the full list, or use ''setDatasetCount(n)'' + ''selectDataset(m, name)'' above.

<code python>
# WRONG — configure() called with an empty list "to fill in later", then datasets() misused as a setter
f.configure("SphereSANS", [])
t = newTable("SphereSANS", 200, 3)
...
f.datasets(["SphereSANS_I"])   # TypeError: datasets(self): too many arguments
f.datasets()                   # "fixes" the crash, but configures nothing — still 0 datasets
f.fit()                        # RuntimeError: failed to read data for the selected dataset(s)

# CORRECT — create the table first, then configure() with the real dataset name
t = newTable("SphereSANS", 200, 3)
...
f.configure("SphereSANS", ["SphereSANS_I"])
f.fit()
</code>

Confirmed live.

----

=====Fit Range=====

^ Function ^ Description ^
| ''setRange(m, min, max)'' | set X range for dataset m (1-indexed) |
| ''setRange(m, min, max, byIndex=True)'' | range by point index instead of X value |
| ''setRangeAll(min, max)'' | set same range for all datasets |

----

=====Fit Algorithm=====

**Use LM for all smooth analytical functions — Simplex is WRONG for them.**
Levenberg-Marquardt (''"LM"'') is the default and converges in fewer than 20 iterations for
Gaussian, sphere, Guinier, cylinder, and any continuously differentiable model.
''setFitMethod("Simplex")'' or ''setFitMethod(0)'' uses derivative-free search; for smooth models
it runs 500–1000 iterations without convergence — do NOT use it for analytical functions.
Only use Simplex for tabulated or step-function models where derivatives are unreliable.

<code python>
# RIGHT — LM converges in <20 iterations for smooth models; or omit (LM is the default)
f.setFitMethod("LM")
</code>

====Method selection====

^ Function ^ Description ^
| ''fitMethod()'' | returns current index (0=Simplex, 1=LM, 2=GenMin) |
| ''setFitMethod(0)'' or ''setFitMethod("Simplex")'' | Nelder-Mead Simplex — **ONLY for non-differentiable/tabulated models** |
| ''setFitMethod(1)'' or ''setFitMethod("LM")'' | Levenberg-Marquardt — **default, use for all smooth analytical functions** |
| ''setFitMethod(2)'' or ''setFitMethod("GenMin")'' | Genetic minimization |
| ''maxIterations()'' / ''setMaxIterations(n)'' | maximum number of iterations |
| ''tolerance()'' / ''setTolerance(v)'' | relative convergence tolerance |
| ''toleranceAbs()'' / ''setToleranceAbs(v)'' | absolute convergence tolerance |

----

====Levenberg-Marquardt options====

^ Function ^ Values ^
| ''setLmControl(n)'' or ''setLmControl(name)'' | ''0''/''gradient-scaled'', ''1''/''gradient-unscaled'', ''2''/''delta-scaled'', ''3''/''delta-unscaled'' |
| ''setLmAdaptStep(bool)'' | adaptive step size (restart on constant Chi²) |
| ''setLmDerivStep(v)'' | derivative step size |

----

====Simplex options====

^ Function ^ Values ^
| ''setSimplexVariant(n)'' or ''setSimplexVariant(name)'' | ''0''/''nmsimplex2'', ''1''/''nmsimplex2rand'', ''2''/''simplex'' |
| ''setSimplexConvRate(n)'' | ''0''=fast(×10), ''1''=normal, ''2''=slow(×0.1) |
| ''setSimplexRestart(bool)'' | restart on constant Chi² |

----

====GenMin options====

^ Function ^ Description ^
| ''setGenMinPopulation(n)'' | population size |
| ''setGenMinGenomeSize(n)'' | genome size |
| ''setGenMinGenerations(n)'' | number of generations |
| ''setGenMinSelectionRate(v)'' | selection rate (0..1) |
| ''setGenMinMutationRate(v)'' | mutation rate (0..1) |
| ''setGenMinRandomSeed(n)'' | random seed |
| ''setGenMinLocalSearch(n)'' or ''setGenMinLocalSearch(name)'' | ''0''/''genmin'', ''1''/''levenberg'', ''2''/''genmin+levenberg'' |

----

=====SANS Instrument=====

^ Function ^ Description ^
| ''instrumentalFit()'' | returns True if SANS mode is active |
| ''setInstrumentalFit(bool)'' | enable/disable SANS resolution and polydispersity |
| ''instrument()'' | current instrument name |
| ''setInstrument(name)'' | ''"SANS"'' (resolution + polydispersity) or ''"Back-Scattering"'' (resolution only) |

----

=====Resolution (post-function)=====

====Global====

^ Function ^ Values ^
| ''setResoFunction(n)'' or ''setResoFunction(name)'' | ''0''/''Gauss-SANS'', ''1''/''Triangular'', ''2''/''Bessel-SANS'', ''3''/''Gauss'', ''4''/''InFunction'' |
| ''setResoSpeedControl(n)'' | 0=Fastest … 6=Perfect, 7=Custom |
| ''setResoWorkspace(n)'' | integration workspace size |
| ''setResoSigmaLimit(n)'' | sigma limit for integration |

----

====Per-dataset (m is 1-indexed)====

^ Function ^ Description ^
| ''resoEnabled(m)'' / ''setResoEnabled(m, bool)'' | enable resolution for dataset m — **no bounds check at all**: an out-of-range ''m'' (e.g. ''0'', or beyond ''datasetCount()'') silently does nothing / returns ''False'', no exception, unlike ''setResoDataset'' below |
| ''resoDataset(m)'' / ''setResoDataset(m, name)'' | resolution σ(Q) source for dataset m — column name or built-in option (see below) |

**''setResoDataset(m, name)'' raises ''ValueError'' if ''name'' doesn't match any available resolution
dataset/option for that index** — same silent-no-op bug ''setWeightingDataset'' had (confirmed live): an
unmatched name used to leave the previous/default selection in place, with no error, so resolution
smearing never actually applied as requested. The combo only populates once
''setInstrumentalFit(True)'' is set — call that first if a real column name isn't being recognized.
''setPolyDataset(m, name)'' (see below) has the identical fix for the identical reason, and its combo
needs ''setPolyEnabled(m, True)'' set first instead.

Built-in ''name'' values (matched by substring, case-sensitive):

^ name ^ σ(Q) ^
| ''"01%"'' | 0.01·Q |
| ''"02%"'' | 0.02·Q |
| ''"05%"'' | 0.05·Q |
| ''"10%"'' | 0.10·Q |
| ''"20%"'' | 0.20·Q |
| ''"ASCII"'' | column from the loaded ASCII.1D.SANS file |

Or pass any σ(Q) column name from your data table.

----

=====Polydispersity (post-function)=====

**Important:** Must be called **after** ''configure()'' / ''setFunction()''. No-op in ''init'' stage (no function loaded).

====Global====

^ Function ^ Values ^
| ''setPolyFunction(n)'' or ''setPolyFunction(name)'' | ''0''/''Gauss'', ''1''/''Schultz-Zimm'', ''2''/''Gamma'', ''3''/''Log-Normal'', ''4''/''Uniform'', ''5''/''Triangular'' |
| ''setPolySpeedControl(n)'' | 0=Fastest … 6=Perfect, 7=Custom |
| ''setPolyWorkspace(n)'' | integration workspace size |
| ''setPolySigmaLimit(n)'' | sigma limit |

----

====Per-dataset (m is 1-indexed)====

**Note:** Polydispersity requires instrument ''"SANS"''. With ''"Back-Scattering"'', these calls are silently ignored.

^ Function ^ Description ^
| ''polyEnabled(m)'' / ''setPolyEnabled(m, bool)'' | enable polydispersity for dataset m — **no bounds check at all**, same silent no-op as ''setResoEnabled'' above for an out-of-range ''m'' |
| ''polyDataset(m)'' / ''setPolyDataset(m, name)'' | polydispersity sigma column for dataset m |

----

=====Weighting (post-function)=====

^ Method index ^ Formula ^
| 0 | ''1/σ^2'' (default, uses error bars) |
| 1 | ''1/Y'' |
| 2 | ''σ'' |
| 3 | ''1/Y^2'' |
| 4 | ''1/Y^a'' |
| 5 | ''1/(c^a + b·Y^a)'' |
| 6 | ''1/(Y^a · c^|Xmax−X|)'' |

----

<code python>
f.setWeightingMethod(0)        # use error bars (default)
f.setWeightingMethod(4)
f.setWeightingA(2.0)           # exponent a for methods 4–6
f.setWeightingB(1.0)           # parameter b for method 5
f.setWeightingC(1.0)           # parameter c for methods 5–6
f.setWeightingXmax(0.3)        # Xmax for method 6

f.setWeightingEnabled(1, True)         # enable weighting for dataset 1
f.setWeightingDataset(1, "table_dI")   # σ column for dataset 1
</code>

Matching read accessors also exist for every setter above (undiscoverable otherwise — they're
not shown in the code block): ''weightingMethod()'', ''weightingA()''/''weightingB()''/
''weightingC()''/''weightingXmax()'', ''weightingEnabled(m)'', ''weightingDataset(m)''.

**RULE — Assigning a column ''Table.PlotDesignation.yErr'' does NOT enable weighted fitting by itself. You must also call ''setWeightingEnabled(m, True)''.**
Without it, weighting silently defaults to OFF (unweighted, every point weight = 1) — no error, no warning. ''chi2''/''f.results()'' still return numbers, so nothing *looks* wrong. But chi²/dof then reflects raw, unnormalized residuals rather than residuals-over-σ, so its magnitude is dominated by the absolute scale of the data rather than actual fit quality. Confirmed: a fit with data ~10⁹ in magnitude reported chi²/dof ≈ 1.5×10¹⁴ unweighted vs. ≈ 1.8 with weighting correctly enabled on the exact same fit — thirteen orders of magnitude apart, silently.

**''m'' here is a 1-based dataset INDEX (int) — not the ''"tablename_colname"'' dataset NAME string used almost everywhere else in this API.** That's the one exception, and it's easy to miss given the rest of this API's convention. Confirmed live, both wrong forms, across separate conversations:
<code python>
f.setWeightingEnabled("Data_I", True)  # WRONG — TypeError: setWeightingEnabled(self, m: int,
                                        #   enabled: bool): argument 1 has unexpected type 'str'
f.setWeightingEnabled(0, True)         # WRONG — ValueError: setWeightingEnabled: dataset 0 out
                                        #   of range (1..N) — valid indices start at 1, not 0
f.setWeightingEnabled(1, True)         # RIGHT — 1 is the first (or only) configured dataset
</code>
<code python>
# WRONG — yErr role is set, but weighting is never enabled; chi²/dof will be
# dominated by absolute data magnitude, not real fit quality — silently.
t.setColumnRole(3, Table.PlotDesignation.yErr)
f.configure(model, ["Data_I"])
chi2 = f.fit()   # chi2 is a real number, but not a meaningful one

# RIGHT — explicitly enable weighting and point it at the error column
t.setColumnRole(3, Table.PlotDesignation.yErr)
f.configure(model, ["Data_I"])
f.setWeightingMethod(0)              # 1/σ² — the standard choice
f.setWeightingEnabled(1, True)
f.setWeightingDataset(1, "Data_dI")  # the actual yErr column name
chi2 = f.fit()
</code>
If comparing chi²/dof across multiple models or datasets, always confirm weighting is enabled first — otherwise the comparison is meaningless regardless of how good the fits look.

**''setWeightingDataset(m, name)'' raises ''ValueError'' if ''name'' isn't the exact, fully-qualified
''"tablename_colname"'' dataset name** — not a bare column name like ''"dI"''. Confirmed live:
''f.setWeightingDataset(1, "dI")'' used to silently no-op (the previous/default weighting-column
selection stayed unchanged, with no error) instead of failing — weighting then never actually applied,
and the fit ran effectively unweighted, exactly the same silent-failure shape as forgetting
''setWeightingEnabled()'' above, just one step further along. Always use the full ''"table_dI"'' form.

**A correctly-formatted ''"table_col"'' name is still not enough — the column must specifically be
the yErr-role one.** ''setWeightingDataset'''s valid-name pool is a strict subset of what
''configure()'''s ''datasets'' argument accepts (X/Y columns too) — see Rule 6 below.

----

=====Parameters=====

**IMPORTANT — exact parameter names required.**
''setParamValue'', ''setParamVaries'', ''paramValue'', and related methods require names **exactly** as returned by ''paramNames()'' — case-sensitive, no abbreviations.
An unknown name raises ''ValueError: setParamValue: no such parameter '<name>'. Available: <comma-separated real names>.'' (or the equivalent for ''setParamVaries''/''paramValue'') — it does not silently fail, and the error itself already names every valid parameter, so a correction never has to guess.
''paramValue'' — the READ accessor — used to silently return ''nan'' for an unknown name instead of raising, unlike the setters; this was fixed so a wrong guess (e.g. ''paramValue("center")'' for a function whose real parameter is ''"x0"'') is caught immediately instead of reporting a misleading ''nan'' as if it were a real result.
Always discover names in an EXPLORE step: ''scriptPrint(str(f.paramNames()))'' — then use those exact strings.
Example: for ''sasviewmodels/sphere'' the names are ''['scale', 'background', 'sld', 'sld_solvent', 'radius']'' —
''setParamValue("R", ...)'' or ''setParamValue("SLD", ...)'' raise ''ValueError'', they don't silently do nothing.

**IMPORTANT — not all parameters are adjustable (fittable).**
Each model defines which parameters can be fitted. After ''configure()'', ''paramVaries(name)'' reflects
the model's built-in adjustability — it returns ''False'' for parameters the model marks as non-adjustable
(e.g. in some SANS-fit functions only ''scale'' is adjustable while ''SLD1''/''SLD2'' are fixed by the model).
**Always call ''paramVaries(name)'' after ''configure()'' to discover which parameters are actually fittable.**
Calling ''setParamVaries("sld1", True)'' on a non-adjustable parameter has no effect.

**For a CUSTOM function you compiled yourself, this "built-in adjustability" is just whatever
''c.setParamVariesAll()'' set (or didn't set) at compile time** — it is not a deliberate modeling
restriction unless you made it one. ''setParamCount(n)'' defaults every new parameter to fixed; if the
compile script never calls ''setParamVariesAll()'', ALL of that function's parameters are permanently
non-adjustable from ''configure()'''s point of view.

**''configure()'' re-derives every parameter's vary/fixed state from that SAME compiled default on
EVERY call — it never inherits a previous fit session's ''setParamVaries()'' overrides.** Calling
''configure()'' a second time (same or different function) resets adjustability back to the compiled
baseline; any ''f.setParamVaries(...)'' calls made before that point are gone. If a script never called
''setParamVariesAll()'' at compile time AND never calls ''setParamVaries()''/''setParamVariesAll()''
again after ''configure()'', ''fit()'' raises ''RuntimeError: fit(): no adjustable parameters — every
parameter is fixed.'' Confirmed live.

All ''dataset'' arguments are **1-indexed** (1 = first dataset).
For ''setParamValue'' and ''setParamVaries'', ''dataset=-1'' (default) applies to **all datasets**.
For limits, ''dataset=-1'' means the global (shared) limit.

----

====Reading parameters====

<code python>
names  = f.paramNames()          # list of parameter names
n      = f.paramCount()          # number of parameters

val    = f.paramValue("Rg")          # value, dataset 1
val2   = f.paramValue("Rg", 2)       # value, dataset 2
err    = f.paramError("Rg")          # fit error (NaN if fixed or not yet fitted)
varies = f.paramVaries("Rg")         # True if free parameter
shared = f.paramShared("Rg")         # True if shared across datasets

lL  = f.paramLimitLeft("Rg")         # global left limit
lR  = f.paramLimitRight("Rg")        # global right limit
lL2 = f.paramLimitLeft("Rg", 1)      # local left limit for dataset 1
bay = f.paramBayesian("Rg")          # True if Bayesian prior active
</code>

----

====Setting parameters====

<code python>
f.setParamValue("Rg", 50.0)           # set value, all datasets (default)
f.setParamValue("Rg", 50.0, 2)        # set value, dataset 2 only
f.setParamVaries("bgd", False)         # fix parameter, all datasets (default)
f.setParamVaries("bgd", False, 1)      # fix parameter, dataset 1 only
f.setParamShared("Rg", True)           # share across datasets (global fit)

# bulk setters — values list ordered as paramNames()
f.setParamValueAll([50.0, 0.001, 1.0])        # set all param values, all datasets
f.setParamValueAll([50.0, 0.001, 1.0], 2)     # set all param values, dataset 2 only
f.setParamVariesAll([True, False, True])       # set varies for all params, all datasets
f.setParamVariesAll([True, False, True], 1)   # set varies for all params, dataset 1 only

# global limits
f.setParamLimits("Rg", 1.0, 500.0)

# global Bayesian prior
f.setParamLimits("Rg", 45.0, 55.0, bayesianYN=True)

# local limits for dataset 1 only
f.setParamLimits("Rg", 1.0, 500.0, dataset=1)

# local Bayesian for dataset 2
f.setParamLimits("bgd", 0.0, 0.01, bayesianYN=True, dataset=2)

# unbounded / one-sided — use float('-inf')/float('inf'), NOT a bracket-notation string like ".."
# (that syntax belongs to Compiler.setParamLimit(), not Fittable.setParamLimits() — the two are
# unrelated APIs with different signatures, even though both are called "parameter limits")
f.setParamLimits("Rg", float('-inf'), float('inf'))   # fully free, no constraint
f.setParamLimits("A1", 0.0, float('inf'))              # lower-bounded only (e.g. amplitude >= 0)
</code>

''paramLimitLeft()''/''paramLimitRight()'' return ''float('-inf')''/''float('inf')'' for a side that's
unbounded — compare with ''== float('-inf')'' / ''== float('inf')'', not by testing for ''None'' or an
empty string.

----

=====Execute=====

^ Function ^ Stage required ^ Returns ^ Description ^
| ''fit()'' | ''"fit"'' | float | run fit; returns Chi²/DoF; prints ''chi²/dof'', ''R²'', ''Q'' to console; raises ''RuntimeError'' if the selected dataset(s) can't be read (e.g. an empty table) |
| ''r2()'' | ''"fit"'' | float | R² = 1 − χ²/TSS after last fit or simulate |
| ''qFactor()'' | ''"fit"'' | float | goodness-of-fit probability Q(dof/2, χ²/2); meaningful only with instrumental (error-bar) weighting |
| ''simulate()'' | ''"fit"'' | float | plot fit curves for all datasets |
| ''plotResults(graphName="", graphTemplate="")'' | ''"fit"'' | MultiLayer | plot data + fit curves in a named graph; skips curves already present |
| ''simulateCurve()'' | ''"simulate"'' | float or NaN | run single-curve simulation (Simulate Curve tab) |
| ''beforeFit()'' | ''"fit"'' | — | called internally by ''fit()'' — do NOT call manually |
| ''afterFit()'' | ''"fit"'' | — | called internally by ''fit()'' — do NOT call manually |
| ''curveColor()'' | any | int | current output-curve color index (used by fit, simulate, simulateCurve, plotResults) |
| ''setCurveColor(index)'' | after ''configure()'' | bool | set output-curve color by integer index; returns ''False'' if out of range or called in ''"init"'' stage |
| ''setCurveColor("name")'' | after ''configure()'' | bool | set output-curve color by partial name (case-insensitive); prints available names on failure; call after ''configure()'' and before ''fit()'' / ''plotResults()'' |
| ''setScaleErrors(bool)'' | — | — | scale parameter errors by √(Chi²/DoF) |
| ''setStatisticsNotes(bool)'' | — | — | save covariance matrix to a Note after fit |
| ''setSaveSession(bool)'' | — | — | save session to a Note after fit |

''fit()'', ''simulate()'', ''plotResults()'', ''beforeFit()'' and ''afterFit()'' raise ''RuntimeError'' if stage is not ''"fit"''.
''simulateCurve()'' raises ''RuntimeError'' if stage is not ''"simulate"''.

After ''fit()'' and after ''simulate()'', fit curve tables are created in the workspace:
  * Single dataset: ''fitCurve-<function>''
  * Global fit dataset N: ''fitCurve-<function>-global-N''

**Fit curve table columns** (column index is 1-based for Python cell access):

^ Col ^ Label ^ Role ^ Contents ^
| 1 | ''x'' | X | Q values (same as input data) |
| 2 | ''y'' | Y | fitted curve I(Q) |
| 3 | ''weight'' | yErr | fit weight per point |
| 4 | ''sigma'' | xErr | resolution σ(Q) |
| 5 | ''residues'' | Y | raw, UNNORMALIZED residual ''data − fit'' — NOT divided by σ or weight, despite the name suggesting a normalized residual |
| 6 | ''Characteristics'' | None | label column — one text label per row (e.g. ''"chi^2/DoF"'', ''"R^2=1-chi^2/TSS"''; a parameter name lives in col 8 instead, see below) |
| 7 | ''Conditions'' | None | value column, same row as its label in col 6 — text, prefixed ''"->   "'' |
| 8 | ''Parameters'' | None | one fit parameter NAME per row |
| 9 | ''Values'' | None | that parameter's fitted value, same row |
| 10 | ''Errors'' | None | that parameter's fit error, same row (empty for a pure ''simulate()'', only populated after a real ''fit()'') |

To access the fit curve from Python after ''fit()'':

<code python>
fc = table("fitCurve-" + model)     # model is the string passed to configure()

# add fit line to an existing layer g:
g.insertCurve(fc, "x", "y", Graph.CurveType.Line)

# add residuals to a second layer gr:
gr.insertCurve(fc, "x", "residues", Graph.CurveType.Scatter)

# read fit value at row idx (1-based):
i_fit = fc.cell(2, idx)    # col 2 = "y"
resid = fc.cell(5, idx)    # col 5 = "residues"
</code>

**This table is the ONLY reliable source for chi²/dof, R², and every fitted parameter's value+error
once you're in a LATER script/part — never re-derive these from memory, printed console text, or a
fresh ''f.fit()'' call.** The exact ROW each label/parameter lands on shifts depending on whether
SANS resolution/polydispersity are enabled (confirmed against source —
''fittable-simulate-simulate.cpp''), so **search by label text, never hardcode a row number**:

<code python>
def read_characteristic(fc, label):
    for row in range(1, fc.numRows() + 1):
        if fc.text(6, row).strip() == label:
            return fc.text(7, row).replace("->", "").strip()
    return None

def read_param(fc, name):
    for row in range(1, fc.numRows() + 1):
        if fc.text(8, row).strip() == name:
            value = float(fc.text(9, row).strip())
            err_str = fc.text(10, row).strip()
            error = None if err_str == "---" else float(err_str.replace("±", "").strip())
            return value, error
    return None, None

fc = table("fitCurve-ai/GaussPeakModel")
chi2_dof = float(read_characteristic(fc, "chi^2/DoF"))
r2       = float(read_characteristic(fc, "R^2=1-chi^2/TSS"))
scale_val, scale_err = read_param(fc, "scale")
</code>

**Col 7 (''Conditions'') is always ''"->"'' followed by variable spacing then the value** (e.g.
''"->   7.88510912046E+03"'') — ''read_characteristic()'' above already handles this correctly via
''.replace("->", "").strip()'', which removes the arrow and the leftover whitespace regardless of
exactly how many spaces separate it from the number. Don't assume a fixed-width prefix (e.g.
slicing off the first N characters) — the spacing isn't guaranteed constant across every row.

**Col 10 (''Errors'') is ''"±<number>"'' (e.g. ''"±0.00974278485"'') for a parameter that varied in
the fit, or the literal string ''"---"'' for one that was fixed** — check for ''"---"'' first (no
error to report — the parameter was never adjusted) and strip the ''"±"'' before ''float()''
otherwise. Confirmed live: parsing ''"±0.00974278485"'' directly with ''float()'' raises
''ValueError: could not convert string to float''.

Run an ''# EXPLORE'' first to confirm the exact label text and parameter names for the model in
question (''fc.colNames()'', then print a few rows of cols 6-9) before hardcoding a label string —
this snippet's exact labels are confirmed against source but worth a live sanity check.

**''t.text(col, row)'' used to raise ''TypeError: decoding str is not supported'' for EVERY
non-empty cell, on any table, not just this one** — a Python-3-porting bug (an already-decoded
''str'' was passed through a second, redundant decode step, which CPython explicitly rejects).
Fixed at the source (''qtimod.sip'') as of this build; ''text()'' now works as shown above. If you
see this exact ''TypeError'' on a build predating the fix, use ''cellData(col, row)'' instead — it
reads the same text via a different, already-correct code path and was never affected.

**''t.cell(col, row)'' silently returns ''0.0'' when the cell's text can't be parsed as a number —
it never raises, and there is no way to distinguish "the value really is 0.0" from "parsing
failed."** This is real, confirmed-live-dangerous specifically on these label/value metadata
columns: reading ''cell(7, row)'' at the WRONG row (e.g. a row that holds ''"Number of
Parameters"'' label text like ''"->   4"'' instead of the intended ''chi^2/DoF'' row) doesn't
error — it silently returns ''0.0'', indistinguishable from a genuinely perfect fit. Confirmed
live: exactly this produced ''chi2/dof = 0.0000'' for BOTH of two different models being compared
— a result that should have been immediately suspicious (an identical, implausibly perfect value
for two different fits) but nothing flagged it. Never use ''cell()'' on columns 6-10 of a
''fitCurve'' table — they're genuine text columns; use ''text()''/''cellData()'' and parse the
result yourself, so a parsing failure is at least visible in the string you tried to convert.

**Comparing two already-fit models (e.g. "fit both Gauss and Lorentzian, pick the better one") is a
solved problem once BOTH models have been fit at least once — the comparison doesn't need to re-fit
anything, guess, or recall printed values.** Both ''fitCurve-<model>'' tables persist as real,
by-name-lookupable objects, exactly like any other table — read both directly. Full worked example
below: compare, select the winner, read one of its parameters, then plot data + the winner's
ACTUAL fit curve + its ACTUAL residuals in two stacked panels (the ''canvasGeometry()''/
''setCanvasGeometry()'' layout below is the confirmed-working pattern — prefer it over guessing at
a ''setLayerGeometry()''/''setLayerRect()''/''setLayout()''-style method, none of which exist):

<code python>
# Both models are assumed already compiled and fit earlier — each fit() call already created its
# own persistent fitCurve-<model> table (see the table structure above).

def read_characteristic(fc, label):
    for row in range(1, fc.numRows() + 1):
        if fc.text(6, row).strip() == label:
            return fc.text(7, row).replace("->", "").strip()
    return None

def read_param(fc, name):
    for row in range(1, fc.numRows() + 1):
        if fc.text(8, row).strip() == name:
            value = float(fc.text(9, row).strip())
            err_str = fc.text(10, row).strip()
            error = None if err_str == "---" else float(err_str.replace("±", "").strip())
            return value, error
    return None, None

# --- Step 1: look up both fit-result tables by name — no re-fit needed ---
fc1 = table("fitCurve-ai/fittable-model1")
fc2 = table("fitCurve-ai/fittable-model2")
if fc1 is None or fc2 is None:
    scriptPrint("One of the fit-curve tables is missing — did both fits actually run?")
else:
    # --- Step 2: read the REAL chi2/dof from each — never recall printed text or fabricate ---
    chi2_1 = float(read_characteristic(fc1, "chi^2/DoF"))
    chi2_2 = float(read_characteristic(fc2, "chi^2/DoF"))
    scriptPrint(f"Model 1 chi2/dof = {chi2_1:.4f}")
    scriptPrint(f"Model 2 chi2/dof = {chi2_2:.4f}")

    # --- Step 3: select the winner ---
    best_fc, best_name = (fc1, "ai/fittable-model1") if chi2_1 <= chi2_2 else (fc2, "ai/fittable-model2")
    scriptPrint(f"Best model: {best_name}")

    # --- Step 4: pull the winner's parameters, e.g. "scale" ---
    scale_val, scale_err = read_param(best_fc, "scale")
    scriptPrint(f"Best-fit scale = {scale_val}" + (f" ± {scale_err}" if scale_err is not None else " (fixed)"))

    # --- Step 5: plot ORIGINAL data + winner's ACTUAL fit curve + ACTUAL residuals, 2 stacked panels ---
    # "y" on best_fc is the MODEL's calculated curve, not the original data (see fitCurve table
    # structure above) — the original data must be added separately, from its own table, or the
    # plot silently shows only the fit line with no data points to compare it against.
    t = table("YourDataTable")   # the table you originally called f.configure(model, ["YourDataTable_y"]) with
    gw = newGraph("FitResults", 2, 2, 1)
    maximizeWindow(gw)
    l1, l2 = gw.layer(1), gw.layer(2)
    l1.enableAxis(Graph.Axis.Bottom, False)
    l2.enableAxis(Graph.Axis.Top, False)
    gw.keepAligned(False)
    gw.setAlignPolicy(GraphWindow.AlignPolicy.AlignCanvases)
    gw.setSpacing(0, 0)
    gw.arrangeLayers(False, False)

    cr1, cr2 = l1.canvasGeometry(), l2.canvasGeometry()
    x, y, w = cr1.x(), min(cr1.y(), cr2.y()), cr1.width()
    total_h = cr1.height() + cr2.height()
    h1 = int(total_h * 0.75)
    l1.setCanvasGeometry(x, y, w, h1)
    l2.setCanvasGeometry(x, y + h1, w, total_h - h1)
    l2.removeTitle()

    l1.addCurve(t, "y", Graph.CurveType.Scatter)                       # ORIGINAL measured data — curve index 0
    l1.insertCurve(best_fc, "x", "y", Graph.CurveType.Line)            # winner's ACTUAL fit curve — index 1
    l2.insertCurve(best_fc, "x", "residues", Graph.CurveType.Scatter)  # winner's ACTUAL residuals

    l1.setCanvasGeometry(x, y, w, h1)          # re-apply — inserting curves can drift the geometry
    l2.setCanvasGeometry(x, y + h1, w, total_h - h1)
    gw.keepAligned(True)
    gw.linkXLayerAxes(True)
    l1.replot()
    l2.replot()
</code>

This directly avoids four separate confirmed-live failures in one step: inventing placeholder
comparison numbers instead of reading real ones, plotting a raw data column (e.g. the ''dI'' error
column) mislabeled as "residuals" instead of the model's actual ''residues'', skipping the best-fit
curve overlay entirely because re-deriving "which model won" seemed to need a re-fit, and — the one
most likely to go unnoticed since nothing errors — plotting ''best_fc'''s "y" alone and mistaking it
for the original data instead of adding the real data table's own curve alongside it.

----

====plotResults(graphName="", graphTemplate="")====

Always plots into **layer 1** (data scatter + fit line).
If the MultiLayer has a second layer and ''radioButtonSameQrange'' is checked, residuals (scatter) are added to **layer 2** and the X axes are linked.
Existing curves are never duplicated — repeated calls are safe.

**Curve order/index in layer 1, per dataset: the DATA scatter is inserted first, the FIT line second.**
For a single dataset that's curve index **0 = data scatter**, index **1 = fit line** — NOT the other way
round, and NOT "the only curve = the line". Styling curve ''0'' via ''Graph.setCurveLineColor()'' after
''plotResults()'' recolors the raw data points, not the fit. To style the fit line's color, prefer
''f.setCurveColor(...)'' (see above) called *before* ''fit()''/''plotResults()'' — it's the purpose-built
API and already applies correctly when the curve is created. Only reach for ''g.setCurveLineColor(1, ...)''
(index **1**, not ''0'') if you need something ''setCurveColor()'' can't express, e.g. an exact ''QColor''
outside its named palette.

<code python>
# WRONG — index 0 is the DATA scatter; this recolors the raw points, not "the line"
f.fit()
f.plotResults("FitResult")
g = graphWindow("FitResult").activeLayer()
g.setCurveLineColor(0, colorIndex("orange"))   # recolors data points, fit line untouched by this call

# RIGHT — set the fit curve's color via Fittable's own API, before fit()/plotResults()
f.setCurveColor("orange")
f.fit()
f.plotResults("FitResult")
</code>

**The fit curve's ''Graph.curveTitle(i)'' is NOT the bare model/function name — it's
''"fitCurve-" + function + "_y"''** (the fit-curve table's name, ''"fitCurve-<function>"'', plus its
''"_y"'' column, following the same ''tablename_colname'' convention as every other curve title).
Checking ''g.curveTitle(i) == model'' (or ''== function'') to verify the fit curve exists will NEVER
match — that string is never the actual title — producing a false "not found" with no error, even
though the fit curve is genuinely there. Confirmed live: exactly this check (''g.curveTitle(i) ==
"ai/SphereFormFactor"'' for a model of that name) silently skipped an entire legend/styling part —
no exception, just a printed "Fit curve missing from graph" and nothing further, even though
''plotResults()'' had just succeeded moments earlier. **Prefer the documented INDEX convention
above (0 = data, 1 = fit) or a plain ''g.numCurves() >= 2'' check** — both are simpler and don't
depend on guessing a title format at all.

<code python>
# WRONG — invented title guess, never matches the real "fitCurve-<function>_y" format
if any(g.curveTitle(i) == model for i in range(g.numCurves())):
    ...  # never true — silently skips everything inside, no error

# RIGHT — trust the already-documented index convention instead
if g.numCurves() >= 2:
    ...
</code>

Confirmed live: a request to "set line color to orange" was fulfilled correctly via ''f.setCurveColor
("orange")'', called in the right place — but the script ALSO added a redundant ''g.setCurveLineColor
(0, colorIndex("orange"))'' afterward, which doesn't touch the fit line at all and instead recolors the
data scatter to match it, unintentionally making data and fit visually blend together.

**''setCurveColor()''/''curveColor()'' are the ONLY curve-styling methods ''Fittable'' has** — there is no
''setCurveLineWidth()'', ''setLineWidth()'', or any symbol-styling setter on ''Fittable''. For line width,
symbol shape/size/color, or anything else ''setCurveColor()'' doesn't cover, get the layer via
''f.plotResults(...).activeLayer()'' (or ''graphWindow(name).activeLayer()'') and call the ''Graph'' method
on curve index **1** (the fit line — see the index note above), e.g. ''g.setCurveLineWidth(1, 2)''.
Confirmed live: ''f.setCurveLineWidth(2)'' raised ''AttributeError: 'fittable18' object has no attribute
'setCurveLineWidth''' — the method simply doesn't exist on ''Fittable''.

^ Parameter ^ Description ^
| ''graphName'' | window name; reuses existing graph or creates new one if not found / empty |
| ''graphTemplate'' | template file stem (without ''.qpt''); applied when creating a new graph |

<code python>
# plain new graph
f.plotResults("FitResults")

# graph styled from templates/graph.qpt
f.plotResults("FitResults", "graph")

# re-running is safe: existing curves are kept, missing ones are added
f.fit()
f.plotResults("FitResults", "graph")

# plotResults returns the MultiLayer — use it to style the plot
gw = f.plotResults("FitResults")
g = gw.activeLayer()
g.setXTitle("q, 1/nm")
g.setYTitle("I(q), 1/cm")
g.setLinOrLogAxis(Graph.Axis.Left, True)   # log Y
</code>

**''plotResults()'' returns a ''MultiLayer'' (''GraphWindow'') — NOT a ''Graph'' (layer). EVERY
layer-level method (''legend()'', ''newLegend()'', ''setLegend()'', ''setXTitle()'',
''setYTitle()'', ''addCurve()'', ''setCurveLineColor()'', ''setCurveSymbolColor()'', etc.) raises
''AttributeError'' if called directly on this return value — always go through ''.activeLayer()''
first.**

<code python>
gw = f.plotResults("FitResults")
g = gw.activeLayer()
g.setXTitle("q, 1/nm")               # NOT gw.setXTitle(...)
leg = g.legend()                     # NOT gw.legend()
if leg is None:
    leg = g.newLegend()              # NOT gw.newLegend()
</code>

Confirmed live, FIVE separate ''AttributeError''s in one conversation, each burning a retry round:
''gw.setXTitle(...)'', ''gw.legend()'', ''gw.setLegend(...)'' (twice), ''gw.newLegend()'' — all
raised ''AttributeError: 'GraphWindow' object has no attribute '<name>''''.

**''plotResults(graphName, ...)'' itself DOES reuse an existing window by that exact name** (it
searches app windows for a name match before creating one) — it was NOT the source of window
pileup in that conversation. The actual orphaned-window driver was an earlier, redundant bare
''plot(t, (...))'' call made BEFORE fitting just to preview the raw data — ''plot()'' has no
name-based reuse at all (see ''ai-python-api.txt'''s "retries re-run the whole script" rule) and
created one new window per retry. If ''plotResults()'' already shows the data (it does, per the
loop above that plots one scatter curve per dataset before the fit line), that earlier preview
''plot()'' call is usually unnecessary. This is the single most common mistake with
''plotResults()'' specifically — see ''graph-python-api.txt'''s own "GraphWindow vs Graph" note
for the general rule this is one instance of.

----

=====Simulate Curve tab=====

''simulateCurve()'' corresponds to the **Simulate Curve** tab; call ''selectStage("simulate")'' first.

Two modes: **uniform X** (generates a new X grid) and **dataset** (uses X values of an existing dataset and returns Chi²/DoF).

----

====Mode selection====

^ Function ^ Description ^
| ''simUniformX()'' | returns True if uniform X mode is active |
| ''setSimUniformX(True)'' | uniform X mode: range defined by From / To / N points |
| ''setSimUniformX(False)'' | dataset mode: use the X values of the selected dataset |

----

====Uniform Q mode====

^ Function ^ Description ^
| ''simFromX()'' / ''setSimFromX(v)'' | X start |
| ''simToX()'' / ''setSimToX(v)'' | X end |
| ''simNumPoints()'' / ''setSimNumPoints(n)'' | number of points |
| ''simLogStep()'' / ''setSimLogStep(bool)'' | logarithmic spacing |

----

====Dataset mode====

^ Function ^ Description ^
| ''simDataset()'' | name of the currently selected dataset |
| ''setSimDataset(name)'' | select dataset by name (partial match); prints available names on failure |

''simulateCurve()'' returns Chi²/DoF in dataset mode; returns ''NaN'' in uniform X mode.

----

====Example====

<code python>
f.selectStage("simulate")

# uniform X: generate model curve over a custom range
f.setSimUniformX(True)
f.setSimFromX(0.005)
f.setSimToX(0.5)
f.setSimNumPoints(200)
f.setSimLogStep(True)
f.simulateCurve()

# dataset mode: simulate and compare to existing data
f.setSimUniformX(False)
f.setSimDataset("table_I")
chi2 = f.simulateCurve()
print(f"simulation Chi²/DoF = {chi2:.4f}")
</code>

----

====simulateCurve()'s output table====

''simulateCurve()'' creates a workspace table named **''"simulatedCurve-" + function''** (''function'' is the string passed to ''configure()'') — same naming scheme as ''fitCurve-<function>'', just a different prefix. **This table has the exact same 10-column layout as ''fitCurve-<function>''** (see "Fit curve table columns" above): ''x'', ''y'', ''weight'', ''sigma'', ''residues'', ''Characteristics'', ''Conditions'', ''Parameters'', ''Values'', ''Errors''. Read/plot it the same way:

<code python>
sc = table("simulatedCurve-" + model)          # model is the string passed to configure()
g.insertCurve(sc, "x", "y", Graph.CurveType.Line)
</code>

**Confirmed live: do NOT pass ''"simulatedCurve-<function>_y"'' to ''table()''** — the ''_y'' suffix is the COLUMN-qualified dataset name (''f.datasets()'' lists datasets as ''"tablename_colname"'', same convention documented above for ''selectDataset()''), not the table name; ''table()'' needs the bare table name without the column suffix. Confirmed live, separately: do NOT reach for ''plot("simulatedCurve-<function>", xMin, xMax, style)'' either — graph-python-api.txt's ''plot("formula", xMin, xMax)'' overload evaluates its string argument as a MATH FORMULA (e.g. ''"x^2"''), not a dataset lookup; passing a table name there silently plots a nonsense function literally labeled with that string, with no exception raised. Use the ''table()'' + ''insertCurve()'' pattern above instead.

----

=====Results=====

====Scalar results====

<code python>
chi2 = f.lastChi2    # Chi²/DoF from last fit
dof  = f.lastDoF     # degrees of freedom
</code>

**IMPORTANT — a good chi²/dof does NOT by itself mean the fit actually finished converging.** ''fit()'' can stop for several different reasons (converged within tolerance, hit the iteration cap, step size too small, ...) — of these, only "hit the iteration cap" (visible in the console as ''iter <N> [<maxIterations>]'' with ''<N>'' equal or very close to ''maxIterations()'') genuinely means the optimizer may have been cut off before reaching a true minimum, not that it found one. The reliable, general check: call ''fit()'' a second time immediately after (same configuration, no changes) — this restarts the optimizer from the just-converged parameters. If chi²/dof barely changes, the fit had already genuinely settled and the result is trustworthy; if the second call's chi²/dof drops meaningfully further, the first run was NOT actually finished. This applies regardless of which specific model or parameter shape is involved — unlike checking a parameter's value against the data's range, which depends on domain judgment and can be flat wrong (a fitted peak center legitimately CAN sit outside the sampled range when the visible tail/wing still constrains it with a tight error bar — that is not a sign of a bad fit).
<code python>
chi1 = f.fit()
chi2 = f.fit()  # re-run from the just-converged parameters — cheap, and confirms convergence
if abs(chi2 - chi1) > 1e-6 * max(abs(chi1), 1e-300):
    scriptPrint(f"Fit may not have fully converged: chi2/dof went from {chi1} to {chi2} on a "
                f"repeat fit — consider raising maxIterations (currently {f.maxIterations()}).")
else:
    scriptPrint(f"Fit converged: chi2/dof = {chi2}, R2 = {f.r2()}")
</code>

**IMPORTANT — ''f.lastChi2'' and ''f.results()'' reflect the CURRENT fit state, not a history — reading them again after a second ''fit()'' call does NOT give you the "before" values.** There is only ever one live result; a second ''fit()'' overwrites it in place. To compare before-vs-after (e.g. "raise maxIterations and compare with the last result"), you must capture the "before" snapshot into local variables **before** calling ''fit()'' again — reading ''f.lastChi2''/''f.results()'' afterward, expecting them to still hold the old values, silently gives you the SAME (new) values twice under different labels, with no error and no warning that the comparison never actually happened.
<code python>
# WRONG — both "old" reads happen AFTER fit() already overwrote the state, so
# chi2_old ends up identical to chi2_new — this is not a real before/after comparison
f.setMaxIterations(1000)
chi2_new = f.fit()
r_new = f.results()
chi2_old = f.lastChi2   # already overwritten — this is the NEW value, not the old one
r_old = f.results()      # same mistake

# RIGHT — capture "before" state first, using the values already in hand
chi2_before = f.lastChi2   # or the chi2 returned by the PREVIOUS fit() call, if still in scope
r_before = f.results()
f.setMaxIterations(1000)
chi2_after = f.fit()        # fit()'s own return value is always the freshest "after" reading
r_after = f.results()
</code>
This is the same underlying gotcha as the convergence check above — ''fit()'''s own return value is always safe to use as a distinct snapshot (it's a fresh value each call), while ''lastChi2''/''results()'' are only safe to read once, at the moment you actually need "the current state," never as a stand-in for "the state from before the call I just made."

----

====results() — structured dict====

<code python>
r = f.results()
</code>

  * **Single dataset:** ''{ name: (value, error), ... }'' — ''error'' is ''None'' for fixed parameters.
  * **Multi-dataset:** ''{ name: [(v1, e1), (v2, e2), ...], ... }''

<code python>
# single dataset
rg_val, rg_err = r["Rg"]
if rg_err is None:
    print("Rg is fixed")

# multi-dataset
for m, (v, e) in enumerate(r["Rg"], start=1):
    print(f"DS{m}: Rg = {v:.3f} ± {e:.3f}")
</code>

**''r["name"]'' is the ''(value, error)'' tuple itself — never index it with ''[0]'', and never pass it
directly into a format spec expecting a number.** Confirmed live, twice in the same conversation:
<code python>
# WRONG — one index too many; r["R"][0] is already the float, not another indexable tuple
r_val = f.results()["R"][0][0]           # TypeError: 'float' object is not subscriptable

# WRONG — the tuple itself, unindexed, passed straight into a numeric format spec
leg.setText(f"R = {f.results()['R']:.2f} nm")   # TypeError: unsupported format string passed to tuple.__format__

# RIGHT — unpack once
r_val, r_err = f.results()["R"]
leg.setText(f"R = {r_val:.2f} nm")
</code>

----

====fitReport() — formatted text====

<code python>
print(f.fitReport())
</code>

Returns the full fit statistics and covariance matrix as a formatted string.
''Fittable.lastFitReport'' holds the same string as a plain attribute.

----

=====Error Handling=====

^ Exception ^ Raised by ^ Condition ^
| ''RuntimeError'' | ''fit()'', ''simulate()'', ''plotResults()'', ''beforeFit()'', ''afterFit()'' | stage is not ''"fit"'' |
| ''RuntimeError'' | ''fit()'' | selected dataset(s) could not be read (e.g. an empty table) — previously returned a stale chi²/dof from a prior fit with no indication anything was wrong |
| ''RuntimeError'' | ''simulateCurve()'' | stage is not ''"simulate"'' |
| ''RuntimeError'' | ''selectStage(s)'' | in ''init'' stage and ''s'' ≠ ''"init"'' |
| ''RuntimeError'' | ''fit()'' | no adjustable parameters — every parameter is fixed |
| ''RuntimeError'' | ''setInstrument()'', ''setInstrumentalFit()'' | invalid usage (see SANS Instrument section) |
| ''ValueError'' | ''setFunction'', ''selectDataset'', ''configure'' | name not found |
| ''ValueError'' | ''selectStage(s)'' | unknown stage name |
| ''ValueError'' | ''setParamValue'', ''setParamVaries'', ''paramValue'' and related | unknown parameter name |
| ''ValueError'' | ''setWeightingDataset'', ''setResoDataset'', ''setPolyDataset'' | bad/unqualified dataset name, or index out of range |
| ''ValueError'' | ''setWeightingEnabled'', ''weightingDataset'' | dataset index out of range |

When a name is not found, available names are printed to the script console before raising.

----

=====Complete Examples=====

====SANS fit with resolution====

<code python>
f = Fittable

# instrument — may be called before or after configure()
f.setInstrumentalFit(True)
f.setInstrument("SANS")    # "SANS" or "Back-Scattering"

# configure: resets to init, loads function, selects dataset
f.configure("sphere_SANS", ["table_I"])

# resolution for dataset 1
f.setResoFunction("Gauss-SANS")
f.setResoSpeedControl(3)
f.setResoEnabled(1, True)
f.setResoDataset(1, "table_sigma")   # or "10%" for 10% dQ/Q

# parameters
f.setParamValue("R", 50.0)
f.setParamLimits("R", 1.0, 1000.0)
f.setParamVaries("bgd", False)

# fit
chi2 = f.fit()
print(f"Chi²/DoF = {chi2:.4f}")
print(f.results())
</code>

----

====Iterative multi-method fit====

<code python>
f = Fittable
f.configure("Guinier", ["table_I"])

f.setFitMethod("Simplex")
f.setMaxIterations(500)
chi2 = f.fit()

if chi2 > 10.0:
    f.setFitMethod("LM")
    chi2 = f.fit()

if chi2 > 10.0:
    f.setFitMethod("GenMin")
    f.setGenMinGenerations(200)
    chi2 = f.fit()

print(f"Final Chi²/DoF = {chi2:.4f}")
r = f.results()
for name in f.paramNames():
    v, e = r[name]
    if e is not None:
        print(f"  {name:12s} = {v:.5E} ± {e:.2E}")
    else:
        print(f"  {name:12s} = {v:.5E}  (fixed)")
</code>

----

====Global fit with shared and local parameters====

<code python>
f = Fittable
f.configure("Guinier", ["table_I1", "table_I2", "table_I3"])

# Rg shared across all datasets, I0 fitted independently
f.setParamShared("Rg", True)
f.setParamLimits("Rg", 1.0, 500.0)

chi2 = f.fit()
r = f.results()

rg_val, rg_err = r["Rg"][0]
print(f"Rg = {rg_val:.3f} ± {rg_err:.3f}")

for m, (v, e) in enumerate(r["I0"], start=1):
    print(f"I0[DS{m}] = {v:.4E} ± {e:.2E}")
</code>

----

====Fit, plot results, then simulate on custom X grid====

<code python>
f = Fittable
f.configure("Guinier", ["table_I"])
chi2 = f.fit()
print(f"fit Chi²/DoF = {chi2:.4f}")

# plot with template; layer 1 = data+fit, layer 2 = residuals (if present)
f.plotResults("FitResults", "graph")

# navigate to Simulate Curve tab and run simulation on custom X grid
f.selectStage("simulate")
f.setSimUniformX(True)
f.setSimFromX(0.001)
f.setSimToX(1.0)
f.setSimNumPoints(500)
f.setSimLogStep(True)
f.simulateCurve()
</code>

----

====Collect fit results into a Table====

<code python>
datasets = ["sample1_I", "sample2_I", "sample3_I"]
results  = []

f = Fittable
for ds in datasets:
    f.configure("Guinier", [ds])
    f.setParamLimits("Rg", 1.0, 500.0)
    chi2 = f.fit()
    r    = f.results()
    rg_v, rg_e = r["Rg"]
    i0_v, i0_e = r["I0"]
    results.append((ds, chi2, rg_v, rg_e, i0_v, i0_e))

t = newTable("FitSummary", len(results), 6)
t.setColNames(["dataset", "chi2", "Rg", "dRg", "I0", "dI0"])
t.setColumnRole(1, Table.Label)
for row, (ds, chi2, rg_v, rg_e, i0_v, i0_e) in enumerate(results, start=1):
    t.setText(1, row, ds)
    t.setCell(2, row, chi2)
    t.setCell(3, row, rg_v)
    t.setCell(4, row, rg_e)
    t.setCell(5, row, i0_v)
    t.setCell(6, row, i0_e)
</code>

----

====Sequential refinement (Simplex seed → LM polish)====

<code python>
f = Fittable
f.configure("sphere_SANS", ["table_I"])

f.setParamValue("R", 40.0)
f.setParamValue("bgd", 0.001)
f.setParamVaries("bgd", True)

# broad exploration with Simplex
f.setFitMethod("Simplex")
f.setMaxIterations(2000)
f.fit()

# precise refinement with LM
f.setFitMethod("LM")
chi2 = f.fit()
print(f"R = {f.paramValue('R'):.2f} ± {f.paramError('R'):.2f}")
print(f"Chi²/DoF = {chi2:.4f}")
</code>

----

====Weighting and range example====

<code python>
f = Fittable
f.configure("PowerLaw", ["table_I"])

# fit only the Guinier–Porod crossover range
f.setRange(1, 0.02, 0.15)

# weight by error bars from table_dI
f.setWeightingMethod(0)
f.setWeightingEnabled(1, True)
f.setWeightingDataset(1, "table_dI")

chi2 = f.fit()
f.plotResults("PowerLaw_fit", "graph")
print(f.results())
</code>

----

====Global fit: inspect per-dataset results====

<code python>
f = Fittable
f.configure("Guinier", ["ds1_I", "ds2_I", "ds3_I"])

f.setParamShared("Rg", True)               # Rg common to all datasets
f.setParamLimits("Rg", 1.0, 300.0)

chi2 = f.fit()
r    = f.results()

print(f"Shared Rg = {r['Rg'][0][0]:.3f} ± {r['Rg'][0][1]:.3f}")
print()
for m in range(1, f.datasetCount() + 1):
    v, e = r["I0"][m - 1]
    print(f"  DS{m}  I0 = {v:.4E} ± {e:.2E}")

f.plotResults("GlobalFit", "graph")
</code>

----

====beforeFit / afterFit workflow====

<code python>
f = Fittable
f.configure("Guinier", ["table_I"])

# load saved parameter values from the UI table into the solver
f.beforeFit()

chi2 = f.fit()

# write fitted values back into the UI parameter table
f.afterFit()

print(f.fitReport())
</code>

''f.beforeFit()'' (same as pressing **Before Fit** in the Fittable interface) does more than sync the
UI table — it also calls the compiled fit function once with its ''beforeFit'' lifecycle flag set,
*before* the fit itself runs. If the function defines a ''beforeFitting(void *ParaM)'' helper in its
''[included functions]'' section (see compile-python-api's ''includedFunctions()''), that's the hook
where it can compute its own initial parameter values from the loaded data instead of relying on
fixed numbers from ''setParamInitsAll()''.

----

====Parameter constraints: two different mechanisms, easy to confuse====

A fit parameter can end up "fixed," or tied to another parameter's value, through two completely
different routes. It matters which one you're using, because they behave differently and live in
different places.

**1. Checkbox-level constraints, decided above the compiled function.** These are resolved by
Fittable itself, before the compiled function ever runs — there is no code involved at all:

  * **Not varying** — ''setParamVaries(name, False)'' (the ''Vary?'' checkbox). The optimizer
    simply never touches this parameter's value; it stays at whatever it's currently set to.
  * **Shared** — ''setParamShared(name, True)'' (the ''Share?'' checkbox), only meaningful with
    more than one dataset. Whatever value this parameter has in dataset 1 is forced onto every
    other dataset — they're not independently fitted, they just copy dataset 1.

Both are plain flags that Fittable's own bookkeeping resolves; you never write anything in
''[code]'' to get this behavior.

**2. In-code constraints, written inside the compiled function.** This is a different animal
entirely: the fit-function author writes ordinary C++ in ''[code]'' that transforms a parameter's
value unconditionally, on every single call — for example ''Rg = fabs(Rg);'' to keep a radius
non-negative. Because this runs on every call (every Q point, every iteration, not just at special
checkpoints), the value the *formula* sees is always corrected — but the value that gets
permanently written back into the optimizer's own parameter vector and into the visible parameter
table only updates during the two single-shot ''beforeFit()''/''afterFit()'' calls described above.
In between — during the many ordinary point-by-point evaluations inside one iteration — the
optimizer's own copy of the parameter can still momentarily sit at an "uncorrected" value; it's
put right again the next time ''beforeFit()''/''afterFit()'' runs, and ''f.fit()'' calls both of
those automatically, so this is invisible in normal use.

**Worked example — keeping two peaks ordered (''x1 < x2'').** Suppose you're fitting two peaks and
want "peak 1" to always mean the left-hand peak. If, during some iteration, the optimizer proposes
values where ''x1 > x2'', a natural fix is to swap the two peaks back into order — but it's easy to
swap only the position and forget everything else that belongs to that peak:

<code cpp>
// WRONG — only the positions swap; amplitude1/width1 now describe the wrong peak
if (x2 < x1) { double t = x1; x1 = x2; x2 = t; }
</code>

After that "wrong" version runs, ''x1''/''x2'' are back in order, but ''amplitude1'' and ''width1''
are still whatever they were before the swap — so they now describe the peak that used to be at
''x2'', not the one now sitting at ''x1''. Every parameter that conceptually belongs to "peak 1"
has to move together:

<code cpp>
// RIGHT — swap every parameter that belongs to each peak, together
if (x2 < x1)
{
    double t;
    t = x1;         x1 = x2;         x2 = t;
    t = amplitude1;  amplitude1 = amplitude2;  amplitude2 = t;
    t = width1;      width1 = width2;          width2 = t;
}
</code>

This should be placed as the very first lines of ''[code]'', before the rest of the formula uses
any of these parameter names.

----

====Parameter sensitivity scan====

<code python>
import numpy as np

f = Fittable
f.configure("Guinier", ["table_I"])
f.setParamVaries("Rg", False)   # hold Rg fixed at each probe point

chi2_list = []
rg_values = np.linspace(10.0, 200.0, 20)

for rg in rg_values:
    f.setParamValue("Rg", float(rg))
    chi2 = f.fit()
    chi2_list.append(chi2)

# store in a table for plotting
t = newTable("RgScan", len(rg_values), 2)
t.setColNames(["Rg", "chi2"])
t.setColumnRole(1, Table.X)
t.setColumnRole(2, Table.Y)
for i, (rg, chi2) in enumerate(zip(rg_values, chi2_list), start=1):
    t.setCell(1, i, float(rg))
    t.setCell(2, i, chi2)
</code>

----

----

=====For AI: Common Mistakes=====

====Rule 1 — Never hardcode a function name from the library====

Function names depend on what is compiled into the running Fittable library and may differ between
installations. **Exception:** if the script itself compiled the function via ''Compiler'', the name is
already known — no discovery needed. For all other cases, discover first.

**WRONG:**
<code python>
f.configure("sasviewmodels/sphere_SANS", ["table_I"])   # hardcoded — may not exist
</code>

**RIGHT — discovery pattern (required when using a pre-existing model). Exclude ''"ai/*"'' from
the candidates — that namespace holds only prior AI-authored one-offs (see
''compile-python-api.txt'' Rule 6), never a vetted library model; matching one there means the
task actually wanted a NEW custom function authored, not an existing model reused:**
<code python>
f = Fittable
hits = [h for h in f.fitFunctions("*sphere*SANS*") if not h.startswith("ai/")]   # most specific first
if not hits:
    hits = [h for h in f.fitFunctions("*sphere*") if not h.startswith("ai/")]    # broaden on miss
if not hits:
    print("No sphere model found. SANS-related models:")
    print(f.fitFunctions("*SANS*"))
    raise RuntimeError("No suitable model found in Fittable library")
model = hits[0]
print(f"Model: {model}")
if len(hits) > 1:
    print(f"Also available: {hits[1:]}")
# use `model` as the function name — never a hardcoded string
f.configure(model, ["table_I"])
</code>

If the expected model is not found: print the search results and raise an error — do not silently
fall back to a wrong model.

====Rule 2 — Never guess whether an explicitly named table/dataset exists====

When a request names a specific table (e.g. "table SAXS_data column I"), the derived
''"tablename_colname"'' string is a *guess*, not a guarantee the table exists at all — the naming
convention only tells you the *shape*, not whether the underlying data is actually there. Verify
before calling ''configure()'', not after it fails:

**WRONG — go straight to ''configure()'' and react to the failure:**
<code python>
f.configure("Guinier", ["SAXS_data_I"])   # if SAXS_data doesn't exist, this wastes a full round-trip
</code>

**RIGHT — check BOTH levels: the table itself, then the specific named column inside it:**
<code python>
if not existTable("SAXS_data"):
    scriptPrint("Table 'SAXS_data' does not exist.")
else:
    t = table("SAXS_data")
    if "I" not in t.colNames():
        scriptPrint("Table 'SAXS_data' exists but has no column named 'I'. Columns: " + str(t.colNames()))
    else:
        f.configure("Guinier", ["SAXS_data_I"])
        f.setParamValue("Rg", 50.0)
        f.fit()
</code>
**Checking ''existTable()'' alone is NOT enough** — it only rules out "the table doesn't exist at
all." A table can exist while the specific named column doesn't, and ''existTable()'' says nothing
about that: ''configure()'' still fails with the exact same runtime ''ValueError'' it would have
raised with no check at all, just one level down (confirmed live: an ''existTable()''-only guard
passed straight through into ''configure()'', which then failed on the missing ''"...I"'' column).
The second check (''"I" not in t.colNames()'') is what actually prevents that.

Use ''existTable("name")'' (an ''app''-level function — see app-python-api), not ''f.datasets()'',
for the table-level check: ''f.datasets()'' only lists Y-columns *already recognized as fit-ready*
(with a Y role assigned), so it can't distinguish "table doesn't exist" from "table exists but
that column isn't set up for fitting" — a real difference the report should state precisely.

**The table name itself can contain underscores — the column name never does.**
''"SAXS_data_I"'' means table ''"SAXS_data"'', column ''"I"'' — split at the LAST underscore, not
the first, since ''"SAXS"'' + ''"data_I"'' would be the wrong split. When going the other
direction (constructing the dataset string from a table+column you already know), this is
unambiguous either way — it only matters when parsing an EXISTING dataset name string back into
its table/column parts.

**This includes passing a dataset name straight to ''table()'' without splitting it first.**
''table()'' takes a TABLE name, not a ''"table_col"'' dataset identifier — passing the whole
string through looks like it should work (it's a valid-looking name) but silently returns
''None'' because no table is actually named that.
<code python>
# WRONG — "SAXS_data_2" is a dataset identifier (table "SAXS_data", column "2"), not a table
# name; table() returns None, and the next line crashes with AttributeError on NoneType
t = table("SAXS_data_2")
scriptPrint("rows=" + str(t.nRows()))

# RIGHT — split at the LAST underscore first, then look up the real table name
t = table("SAXS_data")
scriptPrint("rows=" + str(t.nRows()))
</code>

====Rule 3 — ''setParamInitsAll'' / ''setParamVariesAll'' require Python floats/bools, not strings====

**WRONG:**
<code python>
f.setParamInitsAll(["1.0", "100.0", "0.001"])   # strings — raises TypeError
</code>

**RIGHT:**
<code python>
f.setParamInitsAll([1.0, 100.0, 0.001])
f.setParamVariesAll([True, True, False])
</code>

====Rule 4 — ''configure()'' does not accept formula strings====

**WRONG:**
<code python>
f.configure("a*x + b", ["table_I"])   # raises ValueError
</code>

''configure()'' only accepts names from ''f.fitFunctions()''. To define a custom function, use
the ''Compiler'' API first, then discover it with ''f.fitFunctions()''.

====Rule 5 — Call ordering: function selection separates two phases====

Pre-function calls (''setFitMethod'', dataset count) must come before ''configure()''/''setFunction()''.
Post-function calls (datasets, ranges, parameters) must come after. Mixing the order silently
resets parameter settings.

**WRONG:**
<code python>
f.setParamValue("Rg", 50.0)   # too early — no function selected yet → ValueError: no such parameter 'Rg'
f.configure("Guinier", ["table_I"])
</code>

**RIGHT:**
<code python>
f.configure("Guinier", ["table_I"])
f.setParamValue("Rg", 50.0)   # after function selection
</code>

====Rule 6 — setWeightingDataset()'s valid names are a stricter subset than configure()'s — yErr-role columns only====

''configure(fn, datasets)'' accepts a dataset string built from any plotted-role column (X or Y) —
that's what makes ''"table_I"'' valid there. ''setWeightingDataset(m, name)'' draws from a completely
different, smaller pool: only columns with the **yErr** role ever qualify. A correctly-formatted
''"table_col"'' string for a Y column raises the exact same generic ''ValueError'' as a nonexistent
name — nothing in the message distinguishes "wrong role" from "doesn't exist at all."

**WRONG — ''"Sphere_I"'' looks valid (''I'' really is a column of table ''Sphere''), but ''I'' is the
Y-role column, not the yErr-role column — confirmed live:**
<code python>
t.setColNames(["Q", "I", "dI"])
t.setColumnRole(1, Table.PlotDesignation.X)
t.setColumnRole(2, Table.PlotDesignation.Y)
t.setColumnRole(3, Table.PlotDesignation.yErr)
f.configure("SphereSANS", ["Sphere_I"])   # OK — Y column is the right pool for configure()
f.setWeightingDataset(1, "Sphere_I")      # ValueError — wrong pool: only yErr columns qualify here
</code>

**RIGHT — point at the yErr-role column instead:**
<code python>
f.setWeightingDataset(1, "Sphere_dI")     # dI is the yErr-role column
</code>

If unsure which column is yErr-role, check which column had ''Table.PlotDesignation.yErr''
assigned — don't assume it's the same column named in the main ''configure()'' dataset string.

**A ''newTable()'''d table's columns default to ''Y'' for every column except column 1, which
defaults to ''X'' — there is NO default ''yErr''.** If the intended error column never got an
explicit ''t.setColumnRole(col, Table.PlotDesignation.yErr)'' call at all, it silently stays
''Y''-role: this is why a properly-formatted ''"table_dI"'' string can still be rejected even when
the column genuinely exists and even survives ''configure()'' (which accepts X/Y columns too) —
it's absent specifically from the yErr pool ''setWeightingDataset()'' draws from, and the resulting
error still just says "not one of the available weighting datasets," never "this column has the
wrong role." Confirmed live: a table created via ''newTable("GaussPeak", 150, 3);
t.setColNames(["Q", "I", "dI"])'' with NO ''setColumnRole()'' calls anywhere left ''dI'' defaulted
to ''Y'' — ''f.setWeightingDataset(1, "GaussPeak_dI")'' failed twice in a row, both retries only
re-checking the name string's format, never suspecting the column's actual role, until the
identical-error-repeat guard gave up entirely.

**This is fixable on the spot, even from a LATER part/script — the table doesn't need to be
recreated:**
<code python>
t = table("GaussPeak")
t.setColumnRole(3, Table.PlotDesignation.yErr)   # fix the role on the EXISTING table
f.setWeightingDataset(1, "GaussPeak_dI")          # now succeeds
</code>

If ''setWeightingDataset()'' rejects an otherwise-correct-looking name, check the column's actual
role before assuming the name string itself is wrong — especially when the table was created in an
earlier, separate part/script that might have skipped ''setColumnRole()'' for the error column.

====Rule 7 — A "fitted"/"resulting" value shown in a legend or print must come from f.results(), never a hardcoded literal====

When a request asks to display "the fitted X" (a legend, a print, a label), the value must be
read back from the fit's own output — not reused from an initial parameter guess, a synthetic-data
generation comment, or anything else already visible in context. A hardcoded literal that happens
to match the true/seed value in a test case will silently go on showing that same stale number if
the fit ever converges anywhere else — nothing raises, nothing warns, the legend just becomes
wrong with no indication that it is.

**WRONG — ''"R = 50.00"'' is copied from the parameter's initial guess and never touched again;
confirmed live, the fit's actual result was never queried:**
<code python>
f.setParamValue("R", 50.0)
chi2 = f.fit()
leg.setText("R = 50.00 Å")   # unconditionally the initial guess, regardless of what fit() found
</code>

**RIGHT — read the fitted value back from ''results()'' after ''fit()'' returns:**
<code python>
f.setParamValue("R", 50.0)
chi2 = f.fit()
r_val, r_err = f.results()["R"]
leg.setText(f"R = {r_val:.2f} Å")
</code>

This applies to every "show the fitted/resulting/computed ___" phrasing — the number must trace
back to a ''fit()'' call's actual output (''f.results()'', ''f.lastChi2'', ''f.fitReport()''), never to
something decided before ''fit()'' ran.

====Rule 8 — Don't add resolution/polydispersity/instrumental config the request never asked for====

Instrumental resolution smearing (''setInstrumentalFit()'', ''setResoFunction()'', ''setResoDataset()'',
...) and polydispersity (''setPolyFunction()'', ''setPolyEnabled()'', ''setPolyDataset()'', ...) are
real, optional refinements to a fit — not a default "more thorough" mode to add whenever a request
mentions SANS/SAXS. Treat them exactly like unrequested axis/tick styling: add them only when the
request explicitly asks for resolution smearing, polydispersity, or an instrument-specific fit —
never as an unprompted embellishment.

**WRONG — request only asked for "fit with weighted errors":**
<code python>
f.configure(model, ["data_I"])
f.setInstrumentalFit(True)
f.setResoFunction("Gauss-SANS")
f.setResoEnabled(1, True)
f.setResoDataset(1, "10%")
f.setPolyFunction("Gauss")
f.setPolyEnabled(1, True)
f.setPolyDataset(1, "")          # empty/meaningless — no real sigma column was ever created
f.setWeightingMethod(0)
f.setWeightingEnabled(1, True)
f.setWeightingDataset(1, "data_dI")
chi2 = f.fit()
</code>

**RIGHT — only what was actually asked for:**
<code python>
f.configure(model, ["data_I"])
f.setWeightingMethod(0)
f.setWeightingEnabled(1, True)
f.setWeightingDataset(1, "data_dI")
chi2 = f.fit()
</code>

Confirmed live: a plain "fit with weighted errors" request grew an unrequested resolution-smearing
setup plus ''setPolyDataset(1, "")'' — an empty, meaningless sigma-column argument pointing at
nothing — on top of the actual fit. Beyond being unrequested, every added call is unreviewed
surface area with its own failure modes that have nothing to do with what was asked.

====Rule 9 — f.fit()'s return value is ALREADY chi²/dof — never divide it by dof again====

**''chi2 = f.fit()'' is the REDUCED chi-squared (chi²/dof), not the raw sum-of-squared-residuals —
despite the variable name ''chi2'' used throughout these examples (kept short, matching common
physics shorthand).** Computing ''chi2 / dof'' a second time silently produces a value that's too
small by a factor of ''dof'', with no error to signal the mistake — it still looks like a
plausible number.

**WRONG — divides an already-reduced chi² by ''dof'' a second time:**
<code python>
chi2 = f.fit()
dof = f.lastDoF
scriptPrint(f"chi2/dof = {chi2/dof:.4f}")   # WRONG — chi2 IS ALREADY chi2/dof
</code>

**RIGHT — ''f.fit()'''s return value is the final chi²/dof, use it directly:**
<code python>
chi2_dof = f.fit()
scriptPrint(f"chi2/dof = {chi2_dof:.4f}")
</code>

Confirmed live, both fits in the same conversation: a real fit's own console output printed
''chi²/dof: 1.14126291068E+00'', but the script's ''scriptPrint(f"... chi2/dof = {chi2/dof:.4f}")''
reported ''0.0078'' — exactly ''1.1413 / 146'' (its own ''dof''). A second fit in the same run
showed the identical pattern: real ''chi²/dof: 1.43413853647E+08'' printed as ''975604.4466'' —
exactly the real value divided by its own ''dof'' (147) again. Both wrong values were
self-consistent-looking enough that neither raised any suspicion on their own — the
model-selection logic later in the same script happened to read the correct value directly from
the ''fitCurve'' table instead (see above), so the final "best model" choice was unaffected, but
any report relying on these directly-printed numbers would be wrong by a large, silent factor.
