labforge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ """
2
+ Analysis page: one tab per registered analysis, with a dispatched result pane.
3
+ """
4
+
5
+ import flet as ft
6
+
7
+ from .. import ui
8
+ from ..figures import figure_to_base64, is_figure, unpack_figure
9
+ from . import panel
10
+
11
+ EMPTY = "No analyses registered — add one with Lab.add_analysis."
12
+
13
+
14
+ def section_controls(entry, state, page):
15
+ """
16
+ One analysis's description, kwarg controls, Compute action and result pane,
17
+ as a list of controls. Shared by the tabbed page and the scroll layout.
18
+ """
19
+ pane = ft.Container()
20
+
21
+ def show(value):
22
+ # Swap only the content: the mounted Container stays, so the tab survives.
23
+ pane.content = render_result(value)
24
+
25
+ return panel.section_controls(
26
+ entry, state, state.analysis_values[entry.title], page, "Compute", pane, show
27
+ )
28
+
29
+
30
+ def build(state, page):
31
+ """Build the tabbed analysis page."""
32
+ return panel.build("Analysis", state.lab.analyses, state, page, section_controls, EMPTY)
33
+
34
+
35
+ def render_result(value):
36
+ """
37
+ Dispatch an analysis return to a display control.
38
+
39
+ What an analysis may return is deliberately loose: the author writes plain
40
+ Python and the shape of the return picks the treatment.
41
+
42
+ Parameters
43
+ ----------
44
+ value: object
45
+ dict, list of dicts, DataFrame-like, str, Figure or (fig, ax), or
46
+ anything with a repr as the fallback.
47
+
48
+ Returns
49
+ -------
50
+ A Flet control rendering the value.
51
+ """
52
+ if isinstance(value, dict):
53
+ return two_column_table(value)
54
+ if isinstance(value, list) and value and all(isinstance(row, dict) for row in value):
55
+ return records_table(value)
56
+ if hasattr(value, "columns") and hasattr(value, "itertuples"): # DataFrame, duck-typed
57
+ return records_table(
58
+ [dict(zip(value.columns, row)) for row in value.itertuples(index=False)]
59
+ )
60
+ if isinstance(value, str):
61
+ return ui.body(value)
62
+ if is_figure(value):
63
+ return ui.image(figure_to_base64(unpack_figure(value)))
64
+ return ui.body(f"`{value!r}`")
65
+
66
+
67
+ def format_cell(value):
68
+ """Compact display form of one table cell."""
69
+ return f"{value:.4g}" if isinstance(value, float) else str(value)
70
+
71
+
72
+ def two_column_table(mapping):
73
+ """A {quantity: value} dict as a two-column table."""
74
+ return ui.data_table(
75
+ columns=[
76
+ ft.DataColumn(ft.Text("QUANTITY")),
77
+ ft.DataColumn(ft.Text("VALUE"), numeric=True),
78
+ ],
79
+ rows=[
80
+ ft.DataRow(
81
+ cells=[ft.DataCell(ft.Text(str(key))), ft.DataCell(ft.Text(format_cell(value)))]
82
+ )
83
+ for key, value in mapping.items()
84
+ ],
85
+ )
86
+
87
+
88
+ def records_table(records):
89
+ """A list of homogeneous dicts as a table, columns taken from the first record."""
90
+ columns = list(records[0])
91
+ return ui.data_table(
92
+ columns=[ft.DataColumn(ft.Text(str(name).upper())) for name in columns],
93
+ rows=[
94
+ ft.DataRow(
95
+ cells=[ft.DataCell(ft.Text(format_cell(record.get(name)))) for name in columns]
96
+ )
97
+ for record in records
98
+ ],
99
+ )
@@ -0,0 +1,109 @@
1
+ """
2
+ Shared machinery for the visualization and analysis pages, which differ only in
3
+ what they put on screen.
4
+ """
5
+
6
+ import flet as ft
7
+
8
+ from .. import ui
9
+ from ..controls import build_control
10
+
11
+
12
+ def section_controls(entry, state, values, page, action, output, show):
13
+ """
14
+ Build one registered entry as a stackable list: the optional description,
15
+ the controls card, and output.
16
+
17
+ The output control is persistent: show() mutates it in place rather than
18
+ replacing it, so the open tab survives a re-render. The first paint runs
19
+ from the stored kwargs, so returning to a page shows finished output rather
20
+ than a blank pane.
21
+
22
+ Parameters
23
+ ----------
24
+ values: dict
25
+ The entry's persisted {kwarg: value}; controls read and commit to it.
26
+ action: str
27
+ Action button label ("Render", "Compute").
28
+ output: Control
29
+ The persistent pane show() writes into.
30
+ show: callable
31
+ show(result) -> None, writing the entry function's return into output.
32
+ """
33
+ bindings = {
34
+ name: build_control(name, param, values, page) for name, param in entry.params.items()
35
+ }
36
+ status = ui.mono("")
37
+
38
+ def refresh():
39
+ kwargs = {name: binding.read() for name, binding in bindings.items()}
40
+ try:
41
+ show(entry.func(state.data, **kwargs))
42
+ status.value = ""
43
+ except Exception as err: # surface the author's failure, keep the app alive
44
+ status.value = f"{entry.title} raised {type(err).__name__}: {err}"
45
+
46
+ def on_action(e):
47
+ refresh()
48
+ page.update()
49
+
50
+ refresh() # first paint, from the kwargs the last visit left behind
51
+ controls_card = ui.card(
52
+ ft.Column(
53
+ spacing=12,
54
+ controls=[
55
+ *[binding.row for binding in bindings.values()],
56
+ ui.action_button(action, on_action),
57
+ status,
58
+ ],
59
+ )
60
+ )
61
+ header = [ui.body(entry.desc)] if entry.desc else []
62
+ return [*header, controls_card, output]
63
+
64
+
65
+ def build(title, entries, state, page, section, empty):
66
+ """
67
+ Build a tabbed page — one tab per registered entry, behind the run gate —
68
+ or the placeholder / run-gate scaffold when there is nothing to show.
69
+
70
+ Parameters
71
+ ----------
72
+ section: callable
73
+ section(entry, state, page) -> list of controls for one entry.
74
+ empty: str
75
+ Placeholder text shown when nothing is registered.
76
+ """
77
+ if not entries:
78
+ return ui.page_scaffold(title, [ui.placeholder(empty)])
79
+ if not state.has_data():
80
+ return ui.page_scaffold(title, [ui.needs_run()])
81
+
82
+ # Flet 0.86 Tabs is a controller wrapping a separate TabBar and TabBarView,
83
+ # whose control count must equal length.
84
+ tabs = ft.Tabs(
85
+ length=len(entries),
86
+ expand=True,
87
+ content=ft.Column(
88
+ expand=True,
89
+ controls=[
90
+ ft.TabBar(tabs=[ft.Tab(label=entry.title) for entry in entries]),
91
+ ft.TabBarView(
92
+ expand=True,
93
+ # Each tab body scrolls itself; see the column note below.
94
+ controls=[
95
+ ft.Column(
96
+ expand=True,
97
+ scroll=ft.ScrollMode.AUTO,
98
+ spacing=12,
99
+ controls=section(entry, state, page),
100
+ )
101
+ for entry in entries
102
+ ],
103
+ ),
104
+ ],
105
+ ),
106
+ )
107
+ # Deliberately not a scrolling column: an expanding TabBarView needs bounded
108
+ # ancestor heights, so each tab body scrolls on its own instead.
109
+ return ft.Column(expand=True, spacing=16, controls=[ui.heading(title), tabs])
@@ -0,0 +1,61 @@
1
+ """
2
+ Scroll layout: the whole lab as one continuous page, refreshed in place by Run.
3
+ """
4
+
5
+ import flet as ft
6
+
7
+ from .. import ui
8
+ from . import analysis, simulation, theory, visualization
9
+
10
+
11
+ def divider():
12
+ """Hairline rule between sections."""
13
+ return ft.Divider(height=1, color=ft.Colors.OUTLINE_VARIANT)
14
+
15
+
16
+ def results_content(state, page):
17
+ """The visualization and analysis sections, or the run gate before data."""
18
+ if not state.has_data():
19
+ return ft.Column(spacing=16, controls=[ui.heading("Results"), ui.needs_run()])
20
+
21
+ # Both kinds stack the same way; only the section builder differs.
22
+ sections = [(entry, visualization.section_controls) for entry in state.lab.vizzes]
23
+ sections += [(entry, analysis.section_controls) for entry in state.lab.analyses]
24
+ controls = []
25
+ for entry, section in sections:
26
+ controls += [ui.heading(entry.title), *section(entry, state, page), divider()]
27
+ if controls:
28
+ controls.pop() # no rule after the last section
29
+ return ft.Column(spacing=16, controls=controls)
30
+
31
+
32
+ def build(state, page):
33
+ """
34
+ Build the single-page layout for the whole lab.
35
+
36
+ With no navigation there is no rebuild-on-navigate to refresh downstream
37
+ sections, so Run gets an after_run hook that rebuilds the mounted results
38
+ container in place; the handler's page.update() then paints it in one flush.
39
+ Tabs are avoided deliberately: an expanding TabBarView needs bounded
40
+ ancestor heights, which a scrolling column cannot provide.
41
+ """
42
+ results = ft.Container()
43
+
44
+ def refresh():
45
+ results.content = results_content(state, page)
46
+
47
+ refresh()
48
+ return ft.Column(
49
+ expand=True,
50
+ scroll=ft.ScrollMode.AUTO,
51
+ spacing=16,
52
+ controls=[
53
+ ui.heading("Theory"),
54
+ *theory.content(state),
55
+ divider(),
56
+ ui.heading("Simulation"),
57
+ *simulation.section(state, page, after_run=refresh),
58
+ divider(),
59
+ results,
60
+ ],
61
+ )
@@ -0,0 +1,71 @@
1
+ """
2
+ Simulation page: the worker's controls, a Run action and a status line.
3
+ """
4
+
5
+ import flet as ft
6
+
7
+ from .. import ui
8
+ from ..controls import build_control
9
+
10
+
11
+ def section(state, page, after_run=None):
12
+ """
13
+ Build the worker's intro prose and controls card as a stackable list.
14
+
15
+ Parameters
16
+ ----------
17
+ after_run: callable
18
+ Invoked after a successful run, before the page update — the scroll
19
+ layout uses it to rebuild the visualization and analysis sections.
20
+ """
21
+ worker = state.lab.worker
22
+ bindings = {
23
+ name: build_control(name, param, state.worker_values, page)
24
+ for name, param in worker.params.items()
25
+ }
26
+ # Flet 0.86 has no SnackBar, so status lives in the tree where page.update()
27
+ # always reaches it.
28
+ status = ui.mono(state.run_summary)
29
+
30
+ def on_run(e):
31
+ for name, binding in bindings.items():
32
+ state.worker_values[name] = binding.read()
33
+ try:
34
+ # Synchronous in the handler: workers are expected fast. page.run_task
35
+ # with a worker thread is the seam if long runs ever need a live UI.
36
+ state.run()
37
+ status.value = state.run_summary
38
+ if after_run is not None:
39
+ after_run()
40
+ except Exception as err: # surface the worker's failure, keep the app alive
41
+ status.value = f"Worker raised {type(err).__name__}: {err}"
42
+ page.update()
43
+
44
+ scannable = [name for name, param in worker.params.items() if param.scan]
45
+ scan_note = (
46
+ f" Parameters marked scan ({', '.join(scannable)}) accept comma-separated "
47
+ "values; the worker then runs once per point of the cartesian grid."
48
+ if scannable
49
+ else ""
50
+ )
51
+ intro = ui.body(
52
+ f"Set the parameters of **{worker.func.__name__}** and press Run. "
53
+ "The visualization and analysis sections read the most recent result." + scan_note
54
+ )
55
+ controls_card = ui.card(
56
+ ft.Column(
57
+ spacing=12,
58
+ controls=[
59
+ ui.subheading("Parameters"),
60
+ *[binding.row for binding in bindings.values()],
61
+ ui.action_button("Run", on_run),
62
+ status,
63
+ ],
64
+ )
65
+ )
66
+ return [intro, controls_card]
67
+
68
+
69
+ def build(state, page):
70
+ """Build the simulation page for the registered worker."""
71
+ return ui.page_scaffold("Simulation", section(state, page))
@@ -0,0 +1,40 @@
1
+ """
2
+ Theory page: markdown prose with $$...$$ blocks rendered as LaTeX images.
3
+ """
4
+
5
+ import re
6
+
7
+ from .. import ui
8
+
9
+ EQUATION_BLOCK = re.compile(r"\$\$(.+?)\$\$", re.DOTALL)
10
+
11
+ EMPTY = "No theory registered — pass a markdown file or string to Lab.set_theory."
12
+
13
+
14
+ def content(state):
15
+ """
16
+ The theory controls — prose and equations, or a placeholder when unset.
17
+
18
+ Flet's Markdown control has no reliable LaTeX support, so displayed
19
+ equations are carried in $$...$$ blocks and rendered as images instead.
20
+ Inline math should use Unicode symbols in the prose.
21
+ """
22
+ source = state.lab.theory_source
23
+ if not source:
24
+ return [ui.placeholder(EMPTY)]
25
+ controls = []
26
+ for index, piece in enumerate(EQUATION_BLOCK.split(source)):
27
+ piece = piece.strip()
28
+ if not piece:
29
+ continue
30
+ # split() alternates prose and captures, so odd indices are the equations.
31
+ # An equation collapses to one line: matplotlib renders each line of a
32
+ # multi-line string separately, and a line with an unmatched $ renders
33
+ # as literal text rather than math.
34
+ controls.append(ui.equation(" ".join(piece.split())) if index % 2 else ui.body(piece))
35
+ return controls
36
+
37
+
38
+ def build(state, page):
39
+ """Build the Theory page from the lab's markdown source."""
40
+ return ui.page_scaffold("Theory", content(state))
@@ -0,0 +1,30 @@
1
+ """
2
+ Visualization page: one tab per registered viz, each rendering a figure.
3
+ """
4
+
5
+ from .. import ui
6
+ from ..figures import figure_to_base64, unpack_figure
7
+ from . import panel
8
+
9
+ EMPTY = "No visualizations registered — add one with Lab.add_viz."
10
+
11
+
12
+ def section_controls(entry, state, page):
13
+ """
14
+ One viz's description, kwarg controls, Render action and figure, as a list
15
+ of controls. Shared by the tabbed page and the scroll layout.
16
+ """
17
+ image = ui.image("")
18
+
19
+ def show(value):
20
+ # Swap only the src: the mounted Image stays, so the open tab survives.
21
+ image.src = figure_to_base64(unpack_figure(value))
22
+
23
+ return panel.section_controls(
24
+ entry, state, state.viz_values[entry.title], page, "Render", image, show
25
+ )
26
+
27
+
28
+ def build(state, page):
29
+ """Build the tabbed visualization page."""
30
+ return panel.build("Visualization", state.lab.vizzes, state, page, section_controls, EMPTY)
labforge/param.py ADDED
@@ -0,0 +1,186 @@
1
+ """
2
+ Param specs and the registration-time normalization of an author's spec dict.
3
+ """
4
+
5
+ import inspect
6
+ import re
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass
11
+ class Param:
12
+ """
13
+ One adjustable kwarg of a worker, viz or analysis function.
14
+
15
+ Parameters
16
+ ----------
17
+ kind: str
18
+ "scalar" (float), "int", or "tuple".
19
+ default: object
20
+ Starting value; filled from the function signature default when None.
21
+ bounds: tuple
22
+ (lo, hi) numeric range. Bounded scalars and ints render as sliders,
23
+ unbounded ones as validated text fields.
24
+ step: float
25
+ Slider granularity; (hi - lo) / 100 when omitted, 1 for ints.
26
+ scan: bool
27
+ Worker kwargs only: accept comma-separated values to sweep a grid.
28
+ size: int
29
+ Tuple length (kind "tuple" only).
30
+ label: str
31
+ Control label; the kwarg name when omitted.
32
+ """
33
+
34
+ kind: str = "scalar"
35
+ default: object = None
36
+ bounds: tuple = None
37
+ step: float = None
38
+ scan: bool = False
39
+ size: int = 2
40
+ label: str = None
41
+
42
+
43
+ # Shorthand strings accepted in spec dicts, checked before the N-tuple pattern.
44
+ SHORTHANDS = {
45
+ "scalar": dict(kind="scalar"),
46
+ "int": dict(kind="int"),
47
+ "array": dict(kind="scalar", scan=True),
48
+ "scalar or array": dict(kind="scalar", scan=True),
49
+ "int or array": dict(kind="int", scan=True),
50
+ }
51
+
52
+ TUPLE_PATTERN = re.compile(r"^(\d+)-tuple$")
53
+
54
+
55
+ def parse_shorthand(text):
56
+ """
57
+ Convert a shorthand string — a SHORTHANDS key or "N-tuple" (e.g. "2-tuple")
58
+ — into a Param with kind/scan/size set; default left to signature inference.
59
+ """
60
+ key = text.strip().lower()
61
+ if key in SHORTHANDS:
62
+ return Param(**SHORTHANDS[key])
63
+ match = TUPLE_PATTERN.match(key)
64
+ if match:
65
+ return Param(kind="tuple", size=int(match.group(1)))
66
+ raise ValueError(
67
+ f"Unknown param shorthand {text!r}; expected one of " f"{sorted(SHORTHANDS)} or 'N-tuple'."
68
+ )
69
+
70
+
71
+ def infer_param(default):
72
+ """Infer a Param from a signature default: bool/int, tuple, else scalar."""
73
+ # bool before int: bool is an int subclass, and a checkbox-ish 0/1 is closer
74
+ # to the author's intent than a float slider.
75
+ if isinstance(default, bool):
76
+ return Param(kind="int", default=int(default))
77
+ if isinstance(default, int):
78
+ return Param(kind="int", default=default)
79
+ if isinstance(default, (tuple, list)):
80
+ return Param(kind="tuple", default=tuple(default), size=len(default))
81
+ return Param(kind="scalar", default=float(default))
82
+
83
+
84
+ def normalize_spec(func, spec, skip_first=False, allow_scan=True):
85
+ """
86
+ Validate a spec dict against a function signature and fill in defaults.
87
+
88
+ Every named keyword-capable parameter of func gets a Param: spec entries
89
+ (Param instances or shorthand strings) take precedence, and unspecced
90
+ parameters are inferred from their signature defaults. Fails loudly at
91
+ registration on unknown spec keys, unresolvable defaults, out-of-bounds
92
+ defaults, or a scan spec where scanning is not allowed — so a malformed app
93
+ dies at assembly rather than mid-navigation.
94
+
95
+ Parameters
96
+ ----------
97
+ func: callable
98
+ The registered worker, viz or analysis function.
99
+ spec: dict
100
+ {kwarg name: Param or shorthand str}, or None for pure inference.
101
+ skip_first: bool
102
+ Skip the leading positional parameter (the data argument of viz and
103
+ analysis functions).
104
+ allow_scan: bool
105
+ Reject scan=True entries when False (scanning is a worker concept).
106
+
107
+ Returns
108
+ -------
109
+ dict of {name: Param} in signature order, every default resolved.
110
+ """
111
+ spec = dict(spec or {})
112
+ signature = inspect.signature(func)
113
+ parameters = list(signature.parameters.values())
114
+ if skip_first:
115
+ parameters = parameters[1:]
116
+
117
+ named = [
118
+ p
119
+ for p in parameters
120
+ if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
121
+ ]
122
+ # A **kwargs function can take spec keys its signature never names.
123
+ has_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in parameters)
124
+
125
+ known = {p.name for p in named}
126
+ for key in spec:
127
+ if key not in known and not has_var_keyword:
128
+ raise ValueError(f"{func.__name__} has no parameter {key!r}.")
129
+
130
+ # Signature order drives the control layout; **kwargs-only entries trail it.
131
+ normalized = {}
132
+ order = [p.name for p in named] + [k for k in spec if k not in known]
133
+ defaults = {p.name: p.default for p in named}
134
+ for name in order:
135
+ entry = spec.get(name)
136
+ if isinstance(entry, str):
137
+ entry = parse_shorthand(entry)
138
+ # Resolve the default: spec first, signature second, error last.
139
+ signature_default = defaults.get(name, inspect.Parameter.empty)
140
+ if entry is None:
141
+ if signature_default is inspect.Parameter.empty:
142
+ raise ValueError(
143
+ f"{func.__name__} parameter {name!r} needs a spec entry or a "
144
+ "signature default."
145
+ )
146
+ entry = infer_param(signature_default)
147
+ elif entry.default is None:
148
+ if signature_default is inspect.Parameter.empty:
149
+ raise ValueError(
150
+ f"{func.__name__} parameter {name!r} has no default in its spec "
151
+ "or signature."
152
+ )
153
+ entry.default = signature_default
154
+ validate_param(func, name, entry, allow_scan)
155
+ normalized[name] = entry
156
+ return normalized
157
+
158
+
159
+ def validate_param(func, name, param, allow_scan):
160
+ """
161
+ Check one resolved Param's internal consistency, raising ValueError.
162
+
163
+ Also coerces the default to the declared kind and fills a slider step for
164
+ bounded params, so controls.py can trust both.
165
+ """
166
+ where = f"{func.__name__} parameter {name!r}"
167
+ if param.kind not in ("scalar", "int", "tuple"):
168
+ raise ValueError(f"{where}: unknown kind {param.kind!r}.")
169
+ if param.scan and not allow_scan:
170
+ raise ValueError(f"{where}: scan specs are only valid on the worker.")
171
+ if param.kind == "tuple":
172
+ if param.scan:
173
+ raise ValueError(f"{where}: tuple params cannot be scanned.")
174
+ param.default = tuple(param.default)
175
+ if len(param.default) != param.size:
176
+ raise ValueError(f"{where}: default {param.default} does not match size {param.size}.")
177
+ return
178
+ param.default = int(param.default) if param.kind == "int" else float(param.default)
179
+ if param.bounds is not None:
180
+ if len(param.bounds) != 2 or not param.bounds[0] < param.bounds[1]:
181
+ raise ValueError(f"{where}: bounds must be an increasing (lo, hi) pair.")
182
+ lo, hi = param.bounds
183
+ if not lo <= param.default <= hi:
184
+ raise ValueError(f"{where}: default {param.default} outside bounds {param.bounds}.")
185
+ if param.step is None:
186
+ param.step = 1 if param.kind == "int" else (hi - lo) / 100
labforge/registry.py ADDED
@@ -0,0 +1,36 @@
1
+ """
2
+ The value records the Lab stores per registered function.
3
+
4
+ Dataclasses rather than bare tuples, so page code reads entry.title, not
5
+ entry[1]. A shared contract between lab.py, state.py and the pages.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+
10
+
11
+ @dataclass
12
+ class WorkerSpec:
13
+ """The data-producing function and its control spec."""
14
+
15
+ func: callable
16
+ params: dict
17
+
18
+
19
+ @dataclass
20
+ class VizSpec:
21
+ """One visualization tab: a figure-returning function plus its controls."""
22
+
23
+ func: callable
24
+ title: str
25
+ desc: str
26
+ params: dict
27
+
28
+
29
+ @dataclass
30
+ class AnalysisSpec:
31
+ """One analysis tab: a result-returning function plus its controls."""
32
+
33
+ func: callable
34
+ title: str
35
+ desc: str
36
+ params: dict