modelflowib 2.73__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,2872 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Created on Thu Oct 16 09:43:04 2025
4
+
5
+ @author: ibhan
6
+ """
7
+
8
+ # next generation
9
+
10
+ from dataclasses import dataclass, field, fields
11
+ from typing import List, Optional, Any , Union
12
+ import re
13
+ from IPython.display import display, Math, Latex, Markdown , Image, SVG, display_svg,IFrame
14
+ import inspect
15
+ import functools
16
+
17
+
18
+ from pprint import pformat
19
+ import textwrap
20
+
21
+ from modelmanipulation import tofrml,dounloop, sumunroll
22
+ from modelpattern import find_statements,split_frml,find_frml,list_extract,udtryk_parse,kw_frml_name,commentchar,split_frml_reqopts,rebuild_list
23
+ from modelpattern import namepat, check_syntax_model
24
+ from modelhelp import debug_var
25
+ from model_latex_class import a_latex_model,a_latex_equation,defrack, depower,debrace,defunk
26
+ from modelreport import LatexRepo
27
+
28
+ class ModelSpecificationError(Exception):
29
+ pass
30
+
31
+
32
+ def clean_expressions(original_statements: str) -> str:
33
+ """
34
+ Pipeline
35
+ --------
36
+ 1. Uppercase the DSL.
37
+ 2. Collapse LIST blocks to one line, preserving whether they ended with '$'.
38
+ 3. Run tofrml().
39
+ 4. Rebuild LIST blocks through:
40
+ list_extract(..., add_auto_sublists=False)
41
+ rebuild_list(...)
42
+ """
43
+ import re
44
+
45
+ stmt_start = re.compile(r'^\s*(LIST|TLIST|FRML|DO|ENDDO|DOABLE)\b')
46
+
47
+ def is_list_start(line: str) -> bool:
48
+ # Both LIST and TLIST start a list block. TLIST is the transposed
49
+ # form: the second "row" gives sublist names and values run down
50
+ # the columns. TLIST is converted to standard LIST during
51
+ # normalize_lists() so downstream code only ever sees LIST.
52
+ return bool(re.match(r'^\s*(LIST|TLIST)\b', line))
53
+
54
+ def is_tlist_start(line: str) -> bool:
55
+ return bool(re.match(r'^\s*TLIST\b', line))
56
+
57
+ def collect_list_block(lines, start_index):
58
+ """
59
+ Collect one LIST block starting at start_index.
60
+
61
+ A block continues while the current line ends with '/'.
62
+ A block ends when the current line ends with '$', or when
63
+ the current line does not end with '/'.
64
+ """
65
+ block_lines = [lines[start_index].rstrip()]
66
+ j = start_index + 1
67
+
68
+ while j < len(lines):
69
+ prev = block_lines[-1].rstrip()
70
+
71
+ if prev.endswith('$'):
72
+ break
73
+
74
+ if prev.endswith('/'):
75
+ block_lines.append(lines[j].rstrip())
76
+ j += 1
77
+ continue
78
+
79
+ break
80
+
81
+ return '\n'.join(block_lines), j
82
+
83
+ def _convert_tlist_to_list(flat_no_dollar: str) -> str:
84
+ """
85
+ Convert a flattened TLIST single-line form to standard LIST form.
86
+
87
+ Input (flat, no trailing '$'):
88
+ TLIST <name> = h1 h2 h3 / v11 v12 v13 / v21 v22 v23 / v31 v32 v33
89
+
90
+ Output:
91
+ LIST <name> = h1 : v11 v21 v31 / h2 : v12 v22 v32 / h3 : v13 v23 v33
92
+
93
+ The first '/'-separated chunk after '=' is the header row giving
94
+ sublist names. Each subsequent chunk is one tuple of values, one
95
+ value per sublist. Data therefore runs *down* the columns, hence
96
+ 'transposed list'.
97
+ """
98
+ # strip the leading TLIST keyword
99
+ m = re.match(r'^\s*TLIST\b\s*(.*)$', flat_no_dollar, flags=re.IGNORECASE)
100
+ if not m:
101
+ raise ModelSpecificationError(
102
+ f"Internal: _convert_tlist_to_list called on non-TLIST: {flat_no_dollar!r}"
103
+ )
104
+ body = m.group(1).strip()
105
+
106
+ if '=' not in body:
107
+ raise ModelSpecificationError(
108
+ f"TLIST is missing '=' between name and contents: {flat_no_dollar!r}"
109
+ )
110
+ list_name, rhs = body.split('=', 1)
111
+ list_name = list_name.strip()
112
+ if not list_name:
113
+ raise ModelSpecificationError(
114
+ f"TLIST has empty name: {flat_no_dollar!r}"
115
+ )
116
+
117
+ # split rows on '/'
118
+ raw_rows = [r.strip() for r in rhs.split('/')]
119
+ raw_rows = [r for r in raw_rows if r != '']
120
+ if len(raw_rows) < 2:
121
+ raise ModelSpecificationError(
122
+ f"TLIST {list_name} needs a header row and at least one value row "
123
+ f"(rows separated by '/'); got: {flat_no_dollar!r}"
124
+ )
125
+
126
+ def tokens(row: str):
127
+ # split on whitespace or commas, drop empties
128
+ return [t for t in re.split(r'[\s,]+', row.strip()) if t != '']
129
+
130
+ header = tokens(raw_rows[0])
131
+ value_rows = [tokens(r) for r in raw_rows[1:]]
132
+
133
+ if not header:
134
+ raise ModelSpecificationError(
135
+ f"TLIST {list_name} has empty header row"
136
+ )
137
+
138
+ ncols = len(header)
139
+ for k, vr in enumerate(value_rows, 1):
140
+ if len(vr) != ncols:
141
+ raise ModelSpecificationError(
142
+ f"TLIST {list_name}: value row {k} has {len(vr)} entries "
143
+ f"but header has {ncols} ({header})"
144
+ )
145
+
146
+ # transpose: column j across all value rows becomes the j-th sublist
147
+ sublists = []
148
+ for j, sublist_name in enumerate(header):
149
+ col = [vr[j] for vr in value_rows]
150
+ sublists.append(f"{sublist_name} : {' '.join(col)}")
151
+
152
+ return f"LIST {list_name} = " + ' / '.join(sublists)
153
+
154
+ def normalize_list_block(block: str) -> str:
155
+ """
156
+ Flatten one LIST or TLIST block to a single line.
157
+ Preserve a trailing '$' if present in the original block.
158
+
159
+ TLIST blocks are transposed into the canonical LIST form here, so
160
+ every later stage (tofrml, list_extract, rebuild_list, ...) only
161
+ ever sees LIST.
162
+ """
163
+ saw_dollar = bool(re.search(r'\$\s*$', block))
164
+ is_tlist = bool(re.match(r'^\s*TLIST\b', block))
165
+
166
+ flat = ' '.join(line.strip() for line in block.splitlines())
167
+ flat = re.sub(r'\s+', ' ', flat).strip()
168
+
169
+ # remove one trailing '$' and one trailing '/'
170
+ flat = re.sub(r'\s*\$\s*$', '', flat)
171
+ flat = re.sub(r'\s*/\s*$', '', flat)
172
+
173
+ if is_tlist:
174
+ flat = _convert_tlist_to_list(flat)
175
+
176
+ if saw_dollar:
177
+ flat = flat + ' $'
178
+
179
+ return flat
180
+
181
+ def normalize_lists(text: str) -> str:
182
+ lines = text.splitlines()
183
+ out = []
184
+ i = 0
185
+
186
+ while i < len(lines):
187
+ if not is_list_start(lines[i]):
188
+ out.append(lines[i])
189
+ i += 1
190
+ continue
191
+
192
+ block, next_i = collect_list_block(lines, i)
193
+ out.append(normalize_list_block(block))
194
+ i = next_i
195
+
196
+ return '\n'.join(out)
197
+
198
+ def restore_list_block(block: str) -> str:
199
+ """
200
+ Rebuild one LIST block through list_extract() + rebuild_list().
201
+ """
202
+ saw_dollar = bool(re.search(r'\$\s*$', block))
203
+
204
+ parse_block = block if saw_dollar else block.rstrip() + ' $'
205
+ list_dict = list_extract(parse_block, add_auto_sublists=False)
206
+ rebuilt = rebuild_list(list_dict)
207
+
208
+ if not saw_dollar:
209
+ rebuilt = re.sub(r'\s*\$\s*$', '', rebuilt)
210
+
211
+ return rebuilt
212
+
213
+ def restore_lists(text: str) -> str:
214
+ lines = text.splitlines()
215
+ out = []
216
+ i = 0
217
+
218
+ while i < len(lines):
219
+ if not is_list_start(lines[i]):
220
+ out.append(lines[i])
221
+ i += 1
222
+ continue
223
+
224
+ # after normalize_lists(), LIST blocks are normally single-line,
225
+ # but we keep block collection here for robustness
226
+ block_lines = [lines[i].rstrip()]
227
+ j = i + 1
228
+
229
+ while j < len(lines):
230
+ prev = block_lines[-1].rstrip()
231
+
232
+ if prev.endswith('$'):
233
+ break
234
+
235
+ if prev.endswith('/'):
236
+ block_lines.append(lines[j].rstrip())
237
+ j += 1
238
+ continue
239
+
240
+ if stmt_start.match(lines[j]):
241
+ break
242
+
243
+ break
244
+
245
+ block = '\n'.join(block_lines)
246
+ out.append(restore_list_block(block))
247
+ i = j
248
+
249
+ return '\n'.join(out)
250
+
251
+ up = original_statements.upper()
252
+ flattened = normalize_lists(up)
253
+ frml_added = tofrml(flattened)
254
+ return restore_lists(frml_added)
255
+
256
+ def doable_unroll(in_equations,funks=[]):
257
+ ''' expands all sum(list,'expression') in a model
258
+ returns a new model'''
259
+ import model_latex_class as ml
260
+ nymodel = []
261
+ equations = in_equations[:].upper() # we want do change the e
262
+ # debug_var(equations)
263
+
264
+ for comment, command, value in find_statements(equations):
265
+ # print('>>',comment,'<',command,'>',value)
266
+ # debug_var(comment,command,value)
267
+ if comment:
268
+ nymodel.append(comment)
269
+ else:
270
+ if command == 'DOABLE':
271
+ unrolled = ml.doable(value)
272
+ nymodel.append(unrolled + ' ')
273
+ # debug_var(unrolled)
274
+
275
+ else:
276
+ nymodel.append(command + ' ' + value)
277
+ equations = '\n'.join(nymodel)
278
+ # debug_var(equations)
279
+ return equations
280
+
281
+ # Simple extractor
282
+ def extract_model_from_markdown(md_text: str) -> str:
283
+ """Extracts model lines (starting with '>') from Markdown."""
284
+ return "\n".join(
285
+ re.findall(r'^[>]\s?(.*)', md_text, flags=re.MULTILINE)
286
+ ).strip()
287
+
288
+
289
+
290
+ TAG_RE = re.compile(r'^%\s*@<([^>]+)>')
291
+
292
+
293
+ def extract_latex_equation_block(lines, start):
294
+ block = []
295
+ i = start + 1
296
+
297
+ while i < len(lines) and r"\end{equation}" not in lines[i]:
298
+ block.append(lines[i])
299
+ i += 1
300
+
301
+ return block, i + 1
302
+
303
+
304
+
305
+ def extract_model_from_markdown(md_text: str, list_position: str = "front") -> str:
306
+ """
307
+ Extract ModelFlow model equations from markdown.
308
+
309
+ Parameters
310
+ ----------
311
+ md_text : str
312
+ Markdown text containing model equations and LaTeX.
313
+ list_position : {"front", "end"}
314
+ Where extracted LIST definitions should be placed.
315
+ """
316
+
317
+ if list_position not in {"front", "end"}:
318
+ raise ValueError("list_position must be 'front' or 'end'")
319
+
320
+ model_lines = []
321
+ latex_buffer = [] # collect ALL LaTeX for LIST extraction
322
+
323
+ lines = md_text.splitlines()
324
+ current = ""
325
+ i = 0
326
+ n = len(lines)
327
+
328
+ in_dollars = False
329
+
330
+ while i < n:
331
+ line = lines[i]
332
+ stripped = line.lstrip()
333
+
334
+ # ----------------------------------------------------------
335
+ # Track $$ display math
336
+ # ----------------------------------------------------------
337
+ if stripped.startswith("$$"):
338
+ in_dollars = not in_dollars
339
+ latex_buffer.append(line)
340
+ i += 1
341
+ continue
342
+
343
+ if in_dollars:
344
+ latex_buffer.append(line)
345
+ i += 1
346
+ continue
347
+
348
+ # ----------------------------------------------------------
349
+ # LaTeX equation environment (standalone)
350
+ # ----------------------------------------------------------
351
+ if stripped.startswith(r"\begin{equation}"):
352
+ block, i = extract_latex_equation_block(lines, i)
353
+
354
+ tag = None
355
+ label = None
356
+ body_lines = []
357
+
358
+ for l in block:
359
+ s = l.strip()
360
+ if not s:
361
+ continue
362
+
363
+ latex_buffer.append(s)
364
+
365
+ # % @<...>
366
+ if s.startswith('%'):
367
+ m = TAG_RE.match(s)
368
+ if m:
369
+ tag = m.group(1)
370
+ continue
371
+
372
+ # \label{eq:...}
373
+ if s.startswith(r"\label{eq:"):
374
+ label = s[len(r"\label{eq:"):-1]
375
+ continue
376
+
377
+ body_lines.append(s)
378
+
379
+ if label and body_lines:
380
+ final_tag = f'<{tag}>' if tag else '<>'
381
+ body = " ".join(body_lines)
382
+ model_lines.append(
383
+ latex_to_doable(body, tag=final_tag)
384
+ )
385
+ continue
386
+
387
+ # ----------------------------------------------------------
388
+ # Inline LaTeX (for LIST detection)
389
+ # ----------------------------------------------------------
390
+ if "$" in line:
391
+ latex_buffer.append(line)
392
+
393
+ # ----------------------------------------------------------
394
+ # Markdown continuation >>
395
+ # ----------------------------------------------------------
396
+ if stripped.startswith(">>"):
397
+ current += " " + stripped[2:].lstrip()
398
+ i += 1
399
+ continue
400
+
401
+ # ----------------------------------------------------------
402
+ # Markdown model line >
403
+ # ----------------------------------------------------------
404
+ if stripped.startswith(">"):
405
+ if current.strip():
406
+ model_lines.append(current.strip())
407
+ current = stripped[1:].lstrip()
408
+ i += 1
409
+ continue
410
+
411
+ # ----------------------------------------------------------
412
+ # Flush on non-model line
413
+ # ----------------------------------------------------------
414
+ if current.strip():
415
+ model_lines.append(current.strip())
416
+ current = ""
417
+
418
+ i += 1
419
+
420
+ # ----------------------------------------------------------
421
+ # Final flush
422
+ # ----------------------------------------------------------
423
+ if current.strip():
424
+ model_lines.append(current.strip())
425
+
426
+ # ----------------------------------------------------------
427
+ # GLOBAL LIST extraction (ONCE)
428
+ # ----------------------------------------------------------
429
+ lists = findlists_in_latex(md_text)
430
+ # debug_var(lists,model_lines)
431
+ if lists:
432
+ if list_position == "front":
433
+ model_lines = [lists] + model_lines
434
+ else: # "end"
435
+ model_lines = model_lines + [lists]
436
+
437
+ return "\n".join(model_lines)
438
+
439
+
440
+
441
+ # def findlists_in_latex(input):
442
+ # '''extracte list with sublist from latex'''
443
+ # relevant = re.findall(r'\$LIST\s*\\;\s*[^$]*\$',input.upper())
444
+ # # print(f'{relevant=}')
445
+ # temp1 = [l.replace('$','').replace('\\','')
446
+ # .replace(',',' ').replace(';',' ')
447
+ # .replace('{','').replace('}','').replace('\n','/ \n')
448
+ # for l in relevant]
449
+ # # print(f'\n{temp1=}\n')
450
+ # temp2 = ['LIST ' + l.split('=')[0][4:].strip() +' = '
451
+ # + l.split('=')[0][4:]
452
+ # +' : '+ l.split('=')[1] for l in temp1]
453
+
454
+ # return ('\n'.join(temp2)+'\n')
455
+
456
+
457
+ import re
458
+
459
+ import re
460
+
461
+ def clean_mathjax(s):
462
+ s = (
463
+ s.replace('$$\n','')
464
+ .replace('$','')
465
+ .replace('\\allowbreak',' ')
466
+ .replace('\\\\',' ')
467
+ .replace('\\begin{aligned}','')
468
+ .replace('\\end{aligned}','')
469
+ .replace(r'\{','')
470
+ .replace(r'\}','')
471
+ .replace(',',' ')
472
+ .replace(r'\;',' ')
473
+ .replace('\n','/ \n')
474
+ .replace(r'\_','_')
475
+ )
476
+ # remove line-start + blanks
477
+ return re.sub(r'(?m)^\s+', '', s)
478
+
479
+
480
+
481
+ def findlists_in_latex(input):
482
+ '''extract list with possible MathJax line breaks'''
483
+
484
+ relevant = re.findall(
485
+ r'\${1,2}\s*LIST\s*\\;\s*.*?\${1,2}',
486
+ input,
487
+ flags=re.IGNORECASE | re.DOTALL
488
+ )
489
+ temp0 = [l.upper() for l in relevant]
490
+ temp1 = [clean_mathjax(l.upper()) for l in relevant]
491
+
492
+ temp2 = [
493
+ 'LIST ' + l.split('=')[0][4:].strip()
494
+ + ' = ' + l.split('=')[0][4:]
495
+ + ' : ' + l.split('=')[1]
496
+ for l in temp1
497
+ ]
498
+ # debug_var(temp0,temp1,temp2)
499
+ result = '\n'.join(temp2) + '\n'
500
+ return result
501
+
502
+
503
+
504
+ def latex_to_doable(temp,tag='<>'):
505
+ """
506
+ Given a LaTeX equation string in `temp`, this function processes and converts it to a more standardized format.
507
+
508
+ Args:
509
+ temp (str): A LaTeX equation string to be processed and standardized.
510
+
511
+ Returns:
512
+ str: A processed and standardized version of the input `temp` string.
513
+
514
+ Raises:
515
+ None.
516
+ """
517
+
518
+ if type(temp) == type(None):
519
+ return None
520
+ trans={r'\left':'',
521
+ r'\right':'',
522
+ # r'\min':'min',
523
+ # r'\max':'max',
524
+ r'\rho':'rho',
525
+ r'\alpha':'alpha',
526
+ r'\beta':'beta',
527
+ r'\tau':'tau',
528
+ r'\sigma':'sigma',
529
+ r'\exp':'exp',
530
+ r'&':'',
531
+ r'\\':'',
532
+ r'\nonumber' : '',
533
+ r'\_' : '_',
534
+ r'_{t}' : '',
535
+ r"_t(?![a-zA-Z0-9])" :'',
536
+ 'logit^{-1}' : 'logit_inverse',
537
+ r'\{' : '{',
538
+ r'\}' : '}',
539
+ r'\begin{split}' : '',
540
+ '\n' :'',
541
+ r'\forall' :'',
542
+ r'\;' :'',
543
+ r'\:' :'',
544
+ r'\,' :'',
545
+ r'\big' :'',
546
+ r'\begin{aligned}' :'',
547
+ r'\end{aligned}' :'',
548
+ r' ' :' ',
549
+
550
+
551
+ }
552
+ ftrans = {
553
+ r'\sqrt':'sqrt',
554
+ r'\Delta':'diff',
555
+ r'\Phi':'NORM.CDF',
556
+ r'\Phi^{-1}':'NORM.PDF'
557
+ }
558
+ regtrans = {
559
+ r'\\Delta ([A-Za-z_][\w{},\^]*)':r'diff(\1)', # \Delta xy => diff(xy)
560
+ r'_{t-([1-9]+)}' : r'(-\1)', # _{t-x} => (-x)
561
+ r'_{t\+([1-9]+)}' : r'(+\1)', # _{t+x} => (+x)
562
+
563
+ # r'\^([\w])' : r'_\1', # ^x => _x
564
+ # r'\^\{([\w]+)\}(\w)' : r'_\1_\2', # ^{xx}y => _xx_y
565
+ r'\^{([\w+-]+)}' : r'__{\1}', # ^{xx} => _xx
566
+ r'\^{([\w+-]+),([\w+-]+)\}' : r'__{\1}__{\2}', # ^{xx,yy} => _xx_yy
567
+ r'\^{([\w+-]+),([\w+-]+),([\w+-]+)\}' : r'__{\1}__{\2}__{\3}', # ^{xx,yy} => _xx_yy
568
+ r'\^{([\w+-]+),([\w+-]+),([\w+-]+),([\w+-]+)\}' : r'__{\1}__{\2}__{\3}__{\4}', # ^{xx,yy} => _xx_yy
569
+ r'\s*\\times\s*':'*' ,
570
+ r'\s*\\cdot\s*':'*' ,
571
+ r'\\text{\[([\w+-,.]+)\]}' : r'[\1]',
572
+ r'\\sum_{('+namepat+r')}\(' : r'sum(\1,',
573
+ r"\\sum_{([a-zA-Z][a-zA-Z0-9_]*)=([a-zA-Z][a-zA-Z0-9_]*)}\(": r'sum(\1 \2=1,',
574
+
575
+ r'\\max_{('+namepat+r')}\(' : r'lmax(\1,',
576
+ r"\\max_{([a-zA-Z][a-zA-Z0-9_]*)=([a-zA-Z][a-zA-Z0-9_]*)}\(": r'lmax(\1 \2=1,',
577
+ r'\\min_{('+namepat+r')}\(' : r'lmin(\1,',
578
+ r"\\min_{([a-zA-Z][a-zA-Z0-9_]*)=([a-zA-Z][a-zA-Z0-9_]*)}\(": r'lmin(\1 \2=1,',
579
+
580
+ }
581
+ # breakpoint()
582
+ try:
583
+ for before,to in ftrans.items():
584
+ temp = defunk(before,to,temp)
585
+ except:
586
+ print(f'{before=} {to=} {temp=}')
587
+ # debug_var(temp)
588
+
589
+ for before,to in trans.items():
590
+ temp = temp.replace(before,to)
591
+ # debug_var(temp)
592
+
593
+ for before,to in regtrans.items():
594
+ temp = re.sub(before,to,temp)
595
+ # debug_var(temp)
596
+ temp = debrace(temp)
597
+ temp = defrack(temp)
598
+ temp = depower(temp)
599
+ temp = ' '.join(temp.split())
600
+ if '__' in temp:
601
+ res = f'doable {tag} {temp}'
602
+ else:
603
+ res = f'{tag} {temp}'
604
+
605
+ if '\\' in res:
606
+ raise ModelSpecificationError(
607
+ f"Some LaTeX has survived in the model (excerpt): {res[:200]}"
608
+ )
609
+ return res
610
+
611
+
612
+
613
+ def apply_replacements(formulas: str, replacements: Union[tuple[str, str], list[tuple[str, str]]]) -> str:
614
+ """
615
+ Apply one or more string replacements to a formula string.
616
+
617
+ Parameters
618
+ ----------
619
+ formulas : str
620
+ The input string containing one or more formulas.
621
+ replacements : tuple[str, str] or list[tuple[str, str]]
622
+ Either a single (pattern, replacement) tuple or
623
+ a list of such tuples.
624
+
625
+ Example:
626
+ ('__dim', '__{banks}__{country}__{ports}')
627
+ or:
628
+ [
629
+ ('__dim', '__{banks}__{country}__{ports}'),
630
+ ('GDP', 'GDP_REAL')
631
+ ]
632
+
633
+ Returns
634
+ -------
635
+ str
636
+ The modified formula string after all replacements have been applied.
637
+ If no replacements are given or replacements is empty, the original
638
+ string is returned unchanged.
639
+ """
640
+ if not replacements:
641
+ return formulas
642
+
643
+ # Ensure replacements is a list of tuples
644
+ if isinstance(replacements, tuple) and len(replacements) == 2 and isinstance(replacements[0], str):
645
+ replacements = [replacements]
646
+
647
+ updated = formulas
648
+ for old, new in replacements:
649
+ updated = updated.replace(old, new)
650
+ return updated
651
+
652
+ def mfmod_list_to_markdown(text: str) -> str:
653
+ """
654
+ Convert MFMod-style list definitions (EViews/ModelFlow format) into
655
+ aligned Markdown tables, while keeping all other lines unchanged.
656
+
657
+ Example:
658
+ Input:
659
+ >list ages = ages : age_0 * age_11
660
+ >list sexes = sexes : female male /
661
+ > fertile : 1 0
662
+ other stuff
663
+
664
+ Output:
665
+ (Markdown tables for lists with blank line before each)
666
+ other stuff
667
+
668
+ Behavior:
669
+ - Detects lines starting with '>list' as list definitions.
670
+ - Parses sublists separated by '/' and items separated by whitespace.
671
+ - Renders Markdown tables with aligned columns and clear '|'.
672
+ - Leaves all non-list lines unchanged (including indentation).
673
+ - Adds one blank line before each rendered list.
674
+ """
675
+
676
+ lines = text.splitlines()
677
+ output = []
678
+
679
+ # Temporary buffers for list parsing
680
+ current_list = None
681
+ current_entries = []
682
+ inside_list = False
683
+ raw_block = [] # keep original lines for fallback
684
+
685
+ def flush():
686
+ """
687
+ Finalize the current list block:
688
+ - If successfully parsed → render as Markdown table.
689
+ - If invalid → output raw block unchanged.
690
+ - Always reset list state.
691
+ """
692
+ nonlocal current_list, current_entries, inside_list, raw_block
693
+ if current_list and current_entries:
694
+ # Add a blank line before each table unless we’re at the top
695
+ if output and output[-1].strip():
696
+ output.append("")
697
+ output.append(render_list_table(current_list, current_entries))
698
+ output.append("") # 👈 ensures a blank line after the table
699
+
700
+ elif inside_list and raw_block:
701
+ # List start detected but not parsed → keep original text
702
+ if output and output[-1].strip():
703
+ output.append("")
704
+ output.extend(raw_block)
705
+
706
+ # Reset for next possible list
707
+ current_list, current_entries, inside_list, raw_block = None, [], False, []
708
+
709
+ # --- Main parsing loop ---
710
+ for line in lines:
711
+ stripped = line.strip()
712
+
713
+ # Case 1: Start of new list definition
714
+ if stripped.startswith(">list"):
715
+ flush() # finish any previous list
716
+ inside_list = True
717
+ raw_block = [line]
718
+
719
+ # Try to extract list name and right-hand content
720
+ match = re.match(r'>list\s+(\w+)\s*=\s*(.*)', stripped)
721
+ if match:
722
+ current_list, rest = match.groups()
723
+ current_entries.extend(parse_sublists(rest))
724
+ else:
725
+ # only name, content may follow later
726
+ current_list = stripped.replace('>list', '').strip()
727
+
728
+ # Case 2: Continuation lines (e.g., '> fertile : 1 0')
729
+ elif inside_list and stripped.startswith(">") and not stripped.startswith(">list"):
730
+ raw_block.append(line)
731
+ content = stripped[1:].strip()
732
+ current_entries.extend(parse_sublists(content))
733
+
734
+ # Case 3: A non-'>' line ends the list block
735
+ elif inside_list and not stripped.startswith(">"):
736
+ flush()
737
+ output.append(line)
738
+
739
+ # Case 4: Outside of any list
740
+ else:
741
+ if inside_list:
742
+ flush()
743
+ output.append(line)
744
+
745
+ # Handle any unfinished list at the end
746
+ flush()
747
+
748
+ return "\n".join(output)
749
+
750
+
751
+ def mfmod_list_to_codeblock(text: str) -> str:
752
+ """
753
+ Wrap MFMod-style list definitions (lines starting with '>list' and following '>' lines)
754
+ inside triple backticks with 'text' language tag so they render as fixed-width blocks
755
+ in Jupyter Book. All non-list lines are left unchanged.
756
+
757
+ Example:
758
+ Input:
759
+ >list ages = ages : age_0 * age_11
760
+ >list sexes = sexes : female male /
761
+ > fertile : 1 0
762
+ other stuff
763
+
764
+ Output:
765
+ ```text
766
+ >list ages = ages : age_0 * age_11
767
+ ```
768
+ ```text
769
+ >list sexes = sexes : female male /
770
+ > fertile : 1 0
771
+ ```
772
+ other stuff
773
+ """
774
+ lines = text.splitlines()
775
+ output = []
776
+ inside_block = False
777
+
778
+ for line in lines:
779
+ stripped = line.strip()
780
+ if stripped.startswith(">list"): # start of new block
781
+ if inside_block: # close previous
782
+ output.append("```")
783
+ inside_block = False
784
+ if output and output[-1].strip():
785
+ output.append("") # blank line before
786
+ output.append("```text")
787
+ output.append(line)
788
+ inside_block = True
789
+ elif inside_block and stripped.startswith(">"): # continuation
790
+ output.append(line)
791
+ elif inside_block: # end of block
792
+ output.append("```")
793
+ inside_block = False
794
+ output.append(line)
795
+ else:
796
+ output.append(line)
797
+
798
+ if inside_block: # close any open block
799
+ output.append("```")
800
+
801
+ return "\n".join(output)
802
+
803
+
804
+ def parse_sublists(text: str) -> list[tuple[str, list[str]]]:
805
+ """
806
+ Parse a text fragment defining one or more sublists into a list of tuples.
807
+
808
+ Example:
809
+ 'sexes : female male / fertile : 1 0'
810
+ → [('sexes', ['female', 'male']), ('fertile', ['1', '0'])]
811
+ """
812
+ result = []
813
+ for part in [p.strip() for p in text.split('/') if p.strip()]:
814
+ if ':' not in part:
815
+ continue
816
+ sub, vals = [p.strip() for p in part.split(':', 1)]
817
+ result.append((sub, vals.split()))
818
+ return result
819
+
820
+
821
+ def render_list_table(list_name: str, entries: list[tuple[str, list[str]]]) -> str:
822
+ """
823
+ Render one parsed list into a Markdown table, preserving structure.
824
+
825
+ Example:
826
+ [('ages', ['age_0', '*', 'age_11'])] →
827
+ > list
828
+ | **list name** | **sublist name** ||| |
829
+ |:------------------|:-------------------------------------------|-:|-:|-:|
830
+ | ages | ages | age_0 | * | age_11 |
831
+ """
832
+ if not entries:
833
+ return ""
834
+
835
+ # Determine max number of value columns
836
+ max_cols = max(len(e[1]) for e in entries)
837
+ pipes = " |" * max_cols
838
+
839
+ # Header and alignment lines (exact Markdown structure)
840
+ header = f"\n| **list name** | **sublist name** {pipes} |\n"
841
+ header += (
842
+ f"|:------------------|:-------------------------------------------|"
843
+ + "|".join(["---:" for _ in range(max_cols)])
844
+ + "|\n"
845
+ )
846
+
847
+ # Align sublist names to the longest one for consistent look
848
+ max_sub_len = max(len(sub) for sub, _ in entries)
849
+
850
+ rows = []
851
+ first = True
852
+ for sublist, items in entries:
853
+ # Only show list name once
854
+ list_cell = list_name if first else ""
855
+ first = False
856
+
857
+ # Pad with blanks if some sublists have fewer entries
858
+ padded_items = items + [""] * (max_cols - len(items))
859
+ row = f"| {list_cell:<18}| {sublist:<{max_sub_len}} | " + " | ".join(padded_items) + " |"
860
+ rows.append(row)
861
+
862
+ return header + "\n".join(rows)
863
+ import re
864
+
865
+ def mfmod_list_to_codeblock(text: str) -> str:
866
+ """
867
+ Wrap any consecutive lines starting with '>' (including '>list' definitions)
868
+ in fenced code blocks (```text) so they render in fixed-width font
869
+ in Jupyter Book. All other lines remain unchanged.
870
+
871
+ Example
872
+ -------
873
+ Input:
874
+ >list ages = ages : age_0 * age_11
875
+ >list sexes = sexes : female male /
876
+ > fertile : 1 0
877
+ other stuff
878
+
879
+ Output:
880
+ ```text
881
+ >list ages = ages : age_0 * age_11
882
+ >list sexes = sexes : female male /
883
+ > fertile : 1 0
884
+ ```
885
+ other stuff
886
+ """
887
+ lines = text.splitlines()
888
+ output = []
889
+ inside_block = False
890
+
891
+ for line in lines:
892
+ stripped = line.strip()
893
+
894
+ # Lines starting with '>' belong to a code block
895
+ if stripped.startswith(">"):
896
+ if not inside_block:
897
+ # add a blank line before starting a block if previous line not blank
898
+ if output and output[-1].strip():
899
+ output.append("")
900
+ output.append("```text")
901
+ inside_block = True
902
+ output.append(line)
903
+ else:
904
+ # close any open block before non-'>' line
905
+ if inside_block:
906
+ output.append("```")
907
+ inside_block = False
908
+ output.append(line)
909
+
910
+ # close any unclosed block at end
911
+ if inside_block:
912
+ output.append("```")
913
+
914
+ return "\n".join(output)
915
+
916
+ import modelnormalize as nz
917
+ from functools import cached_property
918
+
919
+ def render_markdown_model(cell: str, style: str = "mixed") -> str:
920
+ """
921
+ Prepare Markdown text for rendering.
922
+
923
+ Responsibilities:
924
+ - Protect model lines (starting with '>') by wrapping them in code fences
925
+ - Leave LaTeX blocks untouched
926
+ - Do NOT render anything
927
+ """
928
+
929
+ if style == "plain":
930
+ return cell
931
+
932
+ lines = cell.splitlines()
933
+ out = []
934
+
935
+ in_code = False
936
+ in_fence = False
937
+
938
+ def start_code():
939
+ nonlocal in_code
940
+ if not in_code:
941
+ out.append("```text")
942
+ in_code = True
943
+
944
+ def end_code():
945
+ nonlocal in_code
946
+ if in_code:
947
+ out.append("```")
948
+ in_code = False
949
+
950
+ for line in lines:
951
+ stripped = line.lstrip()
952
+
953
+ # Preserve existing fenced blocks
954
+ if stripped.startswith("```"):
955
+ end_code()
956
+ out.append(stripped)
957
+ in_fence = not in_fence
958
+ continue
959
+
960
+ if in_fence:
961
+ out.append(line)
962
+ continue
963
+
964
+ # Model lines → code block
965
+ if stripped.startswith(">"):
966
+ start_code()
967
+ out.append(stripped)
968
+ continue
969
+
970
+ # Normal text
971
+ end_code()
972
+ out.append(line)
973
+
974
+ end_code()
975
+ # debug_var(out)
976
+ return "\n".join(out)
977
+
978
+ import re
979
+ from IPython.display import display, Markdown, Math
980
+
981
+
982
+ import re
983
+ from IPython.display import display, Markdown, Math
984
+
985
+ def display_model(cell: str, spec: str = "markdown"):
986
+ """
987
+ Display a model specification.
988
+
989
+ spec = "markdown" | "latex"
990
+ """
991
+ if spec == "markdown":
992
+ display_markdown_model(cell)
993
+ elif spec == "latex":
994
+ display_latex_model(cell)
995
+ else:
996
+ raise ValueError("spec must be 'markdown' or 'latex'")
997
+
998
+ def display_markdown_model(cell: str):
999
+ cell = render_markdown_model(cell)
1000
+ display_mixed_markdown(cell)
1001
+
1002
+
1003
+
1004
+ def markdown_headings_to_latex(text: str) -> str:
1005
+ lines = []
1006
+ for line in text.splitlines():
1007
+ if line.startswith("### "):
1008
+ lines.append(r"\subsubsection{" + line[4:] + "}")
1009
+ elif line.startswith("## "):
1010
+ lines.append(r"\subsection{" + line[3:] + "}")
1011
+ elif line.startswith("# "):
1012
+ lines.append(r"\section{" + line[2:] + "}")
1013
+ else:
1014
+ lines.append(line)
1015
+ return "\n".join(lines)
1016
+
1017
+ def protect_model_code_latex(md: str) -> str:
1018
+ out = []
1019
+ in_code = False
1020
+
1021
+ for line in md.splitlines():
1022
+ if line.lstrip().startswith(">"):
1023
+ if not in_code:
1024
+ out.append(r"\begin{verbatim}")
1025
+ in_code = True
1026
+ out.append(line.lstrip()[1:])
1027
+ else:
1028
+ if in_code:
1029
+ out.append(r"\end{verbatim}")
1030
+ in_code = False
1031
+ out.append(line)
1032
+
1033
+ if in_code:
1034
+ out.append(r"\end{verbatim}")
1035
+
1036
+ return "\n".join(out)
1037
+
1038
+ from IPython.display import Latex, display
1039
+
1040
+ def display_latex_model(cell: str):
1041
+ # cell = protect_model_code_latex(cell)
1042
+
1043
+ display(Markdown(cell))
1044
+
1045
+
1046
+
1047
+ def display_mixed_markdown(md: str):
1048
+ """
1049
+ Display Markdown mixed with LaTeX safely in Jupyter.
1050
+
1051
+ Supports unlimited alternation of:
1052
+ - Markdown prose
1053
+ - LaTeX equation / align environments
1054
+ - $$ ... $$ blocks
1055
+
1056
+ Rendering rules:
1057
+ - Math() receives ONLY pure math (no environments, no labels, no tags)
1058
+ - Markdown() never sees LaTeX display environments
1059
+ """
1060
+
1061
+ pattern = re.compile(
1062
+ r"\$\$(.*?)\$\$"
1063
+ r"|\\begin\{equation\}(.*?)\\end\{equation\}"
1064
+ r"|\\begin\{align\}(.*?)\\end\{align\}",
1065
+ flags=re.DOTALL,
1066
+ )
1067
+
1068
+ pos = 0
1069
+
1070
+ def _clean_math_body(math: str) -> str:
1071
+ """
1072
+ Remove non-math directives from LaTeX before MathJax rendering.
1073
+ """
1074
+ LABEL_RE = re.compile(r"\\label\{[^}]*\}")
1075
+ TAG_RE = re.compile(r"%\s*@<[^>]*>")
1076
+ COMMENT_RE = re.compile(r"%.*?$", re.MULTILINE)
1077
+
1078
+ math = LABEL_RE.sub("", math)
1079
+ math = TAG_RE.sub("", math)
1080
+ math = COMMENT_RE.sub("", math)
1081
+ return math.strip()
1082
+
1083
+
1084
+
1085
+ for m in pattern.finditer(md):
1086
+ # ---- Markdown before math ----
1087
+ if m.start() > pos:
1088
+ text = md[pos:m.start()]
1089
+ if text.strip():
1090
+ display(Markdown(text))
1091
+
1092
+ # ---- Math block ----
1093
+ raw_math = next(g for g in m.groups() if g is not None)
1094
+ clean_math = _clean_math_body(raw_math)
1095
+ if clean_math:
1096
+ display(Math(clean_math))
1097
+
1098
+ pos = m.end()
1099
+
1100
+ # ---- Trailing Markdown ----
1101
+ rest = md[pos:]
1102
+ if rest.strip():
1103
+ display(Markdown(rest))
1104
+
1105
+
1106
+
1107
+
1108
+
1109
+ # =============================================================================
1110
+ # Optional in-Makemodel estimation support
1111
+ # =============================================================================
1112
+
1113
+ def _split_frml_options(frml_name: str) -> list[str]:
1114
+ """Return comma-separated options inside a FRML-name flag."""
1115
+ if not frml_name:
1116
+ return []
1117
+ text = str(frml_name).strip()
1118
+ if text.startswith('<') and text.endswith('>'):
1119
+ text = text[1:-1]
1120
+ return [part.strip() for part in text.split(',') if part.strip()]
1121
+
1122
+
1123
+ def _frml_option_value(frml_name: str, key: str, default=None):
1124
+ """Read KEY or KEY=value from a FRML-name flag using kw_frml_name.
1125
+
1126
+ ``EST`` is accepted as a short alias for ``ESTIMATOR``:
1127
+
1128
+ <est=ls> -> estimator named ``ls``
1129
+ <est> -> use the global/default estimator
1130
+ <estimator=ols> -> built-in OLS estimator
1131
+
1132
+ ``kw_frml_name`` now returns True for bare flags. For compatibility with
1133
+ older versions that returned 1, this helper normalizes 1 to True.
1134
+ """
1135
+ aliases = {
1136
+ "ESTIMATOR": ("ESTIMATOR", "EST"),
1137
+ }
1138
+ keys = aliases.get(str(key).upper(), (key,))
1139
+ for lookup_key in keys:
1140
+ value = kw_frml_name(frml_name, lookup_key, default=None)
1141
+ if value is not None:
1142
+ return True if value == 1 else value
1143
+ return default
1144
+
1145
+
1146
+ def _strip_frml_options(frml_name: str, remove_keys: set[str]) -> str:
1147
+ """Remove selected options from a FRML-name flag and rebuild <...>.
1148
+
1149
+ Kept for compatibility with older helper code. Makemodel now preserves
1150
+ estimation options such as ``estimator``/``est`` and ``smpl`` in emitted
1151
+ FRML names, so normal model construction no longer calls this helper.
1152
+ """
1153
+ remove_keys = {k.upper() for k in remove_keys}
1154
+ kept = []
1155
+ for part in _split_frml_options(frml_name):
1156
+ lhs = part.partition('=')[0].strip().upper()
1157
+ if lhs not in remove_keys:
1158
+ kept.append(part)
1159
+ return '<' + ','.join(kept) + '>' if kept else '<>'
1160
+
1161
+
1162
+ def _parse_smpl(smpl, df=None):
1163
+ """Parse an estimation sample specification.
1164
+
1165
+ Preferred FRML syntax is::
1166
+
1167
+ <smpl=start end>
1168
+
1169
+ where ``start`` and ``end`` are separated by one or more blanks. The
1170
+ labels are **not** coerced to integers, because the dataframe index may be
1171
+ a PeriodIndex, DatetimeIndex, quarterly strings, or another custom index
1172
+ type. If ``df`` is supplied, its index is used via ``slice_locs`` to
1173
+ validate the sample and to return the actual boundary labels present in
1174
+ the dataframe.
1175
+
1176
+ Backwards compatibility: ``start:end`` is still accepted.
1177
+ """
1178
+ if smpl in (None, '', False):
1179
+ return None
1180
+
1181
+ def _slice_on_df(start, end, df_):
1182
+ """Return (first_label, last_label) selected by df_.index.slice_locs.
1183
+
1184
+ Warns when the requested upper bound is past the last index label and
1185
+ the sample is silently truncated. ``slice_locs`` accepts an end
1186
+ beyond the index without complaining, so without this check users
1187
+ can ask for ``smpl=2010 2025`` against data that ends in 2020 and
1188
+ not notice the sample stopped early.
1189
+ """
1190
+ if df_ is None:
1191
+ return (start, end)
1192
+
1193
+ idx = df_.index
1194
+
1195
+ attempts = [(start, end)]
1196
+ # Compatibility only: if the index is numeric and the user wrote
1197
+ # ``smpl=2012 2019`` as text, try integer labels after trying the raw
1198
+ # labels. We never coerce first, so quarterly/date/string labels keep
1199
+ # their natural interpretation.
1200
+ try:
1201
+ attempts.append((int(start), int(end)))
1202
+ except Exception:
1203
+ pass
1204
+
1205
+ last_error = None
1206
+ for s, e in attempts:
1207
+ try:
1208
+ istart, iend = idx.slice_locs(s, e)
1209
+ per = idx[istart:iend]
1210
+ if len(per) == 0:
1211
+ raise IndexError(
1212
+ f"sample {start!r} {end!r} selects no rows"
1213
+ )
1214
+ # Warn when the user asked for an end that the index did not
1215
+ # actually contain. Compare against the original requested
1216
+ # end (e), not the resolved per[-1].
1217
+ try:
1218
+ if e is not None and per[-1] != e:
1219
+ # If the index has any label strictly less than
1220
+ # per[-1] but greater than or equal to e, that means
1221
+ # e was *between* labels and we rounded down — fine.
1222
+ # The problematic case is e being beyond idx[-1].
1223
+ if e > idx[-1]:
1224
+ print(
1225
+ f"⚠️ Sample upper bound {e!r} is past the "
1226
+ f"end of the index ({idx[-1]!r}); using "
1227
+ f"{per[-1]!r} instead."
1228
+ )
1229
+ except Exception:
1230
+ # Comparison may fail across mixed index types; just skip
1231
+ # the warning rather than raising.
1232
+ pass
1233
+ return (per[0], per[-1])
1234
+ except Exception as exc:
1235
+ last_error = exc
1236
+
1237
+ raise ModelSpecificationError(
1238
+ f"Could not resolve sample {start!r} {end!r} on dataframe index "
1239
+ f"of type {type(idx).__name__}: {last_error}"
1240
+ )
1241
+
1242
+ if isinstance(smpl, slice):
1243
+ start, end = smpl.start, smpl.stop
1244
+ if start is None or end is None:
1245
+ return smpl
1246
+ return _slice_on_df(start, end, df)
1247
+
1248
+ if isinstance(smpl, (tuple, list)):
1249
+ if len(smpl) != 2:
1250
+ raise ModelSpecificationError(
1251
+ f"Sample tuple/list must have exactly two elements: {smpl!r}"
1252
+ )
1253
+ return _slice_on_df(smpl[0], smpl[1], df)
1254
+
1255
+ text = str(smpl).strip()
1256
+ if not text:
1257
+ return None
1258
+
1259
+ # New preferred syntax: <smpl=start end>. Keep old colon syntax so older
1260
+ # notebooks do not break.
1261
+ if ':' in text and len(text.split()) == 1:
1262
+ start, end = [p.strip() for p in text.split(':', 1)]
1263
+ else:
1264
+ parts = text.split()
1265
+ if len(parts) != 2:
1266
+ raise ModelSpecificationError(
1267
+ "SMPL must be written as 'start end' separated by blanks "
1268
+ f"(or legacy 'start:end'); got {smpl!r}"
1269
+ )
1270
+ start, end = parts
1271
+
1272
+ return _slice_on_df(start, end, df)
1273
+
1274
+
1275
+ def _estimator_display_name(estimator_spec) -> str:
1276
+ """Short user/debug label for either a string estimator or a callable factory."""
1277
+ if isinstance(estimator_spec, str):
1278
+ return estimator_spec.strip().lower()
1279
+ name = getattr(estimator_spec, "__name__", None)
1280
+ if name and name != "factory":
1281
+ return name
1282
+ qualname = getattr(estimator_spec, "__qualname__", None)
1283
+ if qualname:
1284
+ return qualname
1285
+ return type(estimator_spec).__name__
1286
+
1287
+
1288
+ def _estimator_factory_defaults(estimator_callable) -> dict:
1289
+ """Return defaults attached by modelestimator_new.with_defaults(...).
1290
+
1291
+ Makemodel needs the factory defaults before instantiating the estimator so
1292
+ equation-local ``smpl=...`` can be parsed against the same dataframe index
1293
+ that the estimator will use. The important point is that this does **not**
1294
+ call the factory: constructing an EstimatorBackend runs the estimation in
1295
+ ``__post_init__``.
1296
+
1297
+ New modelestimator_new factories expose:
1298
+
1299
+ factory._estimator_defaults = {"input_df": input_df, **default_kwargs}
1300
+ factory._estimator_class = cls
1301
+
1302
+ A small non-calling fallback for ``functools.partial`` and simple callable
1303
+ objects is kept for user-defined factories.
1304
+ """
1305
+ defaults = getattr(estimator_callable, "_estimator_defaults", None)
1306
+ if isinstance(defaults, dict):
1307
+ return defaults
1308
+
1309
+ # functools.partial or similar callables.
1310
+ keywords = getattr(estimator_callable, "keywords", None)
1311
+ if isinstance(keywords, dict):
1312
+ return dict(keywords)
1313
+
1314
+ # Callable objects may expose defaults explicitly.
1315
+ defaults = getattr(estimator_callable, "defaults", None)
1316
+ if isinstance(defaults, dict):
1317
+ return defaults
1318
+ defaults = getattr(estimator_callable, "_defaults", None)
1319
+ if isinstance(defaults, dict):
1320
+ return defaults
1321
+
1322
+ return {}
1323
+
1324
+
1325
+ def _input_df_from_estimator_factory(estimator_callable):
1326
+ """Return the input_df captured by an estimator factory, if exposed."""
1327
+ return _estimator_factory_defaults(estimator_callable).get("input_df")
1328
+
1329
+
1330
+ def _infer_input_df_from_estimator_spec(estimator_spec, estimator_classes: Optional[dict] = None):
1331
+ """Infer the dataframe carried by an estimator class/factory, if possible.
1332
+
1333
+ This is only used to parse equation-local samples. It lets
1334
+
1335
+ ls = Estimate_nls.with_defaults(input_df=npl, smpl=(2011, 2019))
1336
+ <estimator=ls,smpl=2013 2018> ...
1337
+
1338
+ convert ``2013`` and ``2018`` using ``npl.index`` even when Makemodel itself
1339
+ was not constructed with ``input_df=npl``. The factory is not called during
1340
+ this discovery step, so no dummy estimation is run.
1341
+ """
1342
+ try:
1343
+ constructor = _get_estimator_class(estimator_spec, estimator_classes)
1344
+ except Exception:
1345
+ return None
1346
+ return _input_df_from_estimator_factory(constructor)
1347
+
1348
+ def _caller_estimator_namespace() -> dict:
1349
+ """Return caller locals/globals so notebook names can be used in <estimator=name>.
1350
+
1351
+ This is intentionally best-effort. Explicit ``estimator_classes`` passed to
1352
+ Makemodel still takes precedence over anything found in the caller scope.
1353
+ """
1354
+ import inspect
1355
+
1356
+ frame = inspect.currentframe()
1357
+ try:
1358
+ frame = frame.f_back if frame else None
1359
+ while frame is not None:
1360
+ module_name = frame.f_globals.get('__name__')
1361
+ function_name = frame.f_code.co_name
1362
+ # Skip frames inside this module and the generated dataclass __init__.
1363
+ if module_name != __name__ and function_name not in {'__init__'}:
1364
+ namespace = {}
1365
+ namespace.update(frame.f_globals)
1366
+ namespace.update(frame.f_locals)
1367
+ return namespace
1368
+ frame = frame.f_back
1369
+ finally:
1370
+ del frame
1371
+ return {}
1372
+
1373
+
1374
+ def _merge_estimator_namespaces(
1375
+ explicit: Optional[dict] = None,
1376
+ namespace: Optional[dict] = None,
1377
+ ) -> dict:
1378
+ """Merge caller-scope estimator names with explicit estimator_classes.
1379
+
1380
+ Only callable objects are copied from the caller namespace. Explicit
1381
+ estimator_classes wins on name collisions. Keys are kept as written;
1382
+ lookup remains case-insensitive in _get_estimator_class().
1383
+ """
1384
+ merged = {}
1385
+ for source in (namespace or {},):
1386
+ for key, value in source.items():
1387
+ if isinstance(key, str) and callable(value):
1388
+ merged[key] = value
1389
+ for key, value in (explicit or {}).items():
1390
+ merged[key] = value
1391
+ return merged
1392
+
1393
+
1394
+ def _get_estimator_class(estimator_name, estimator_classes: Optional[dict] = None):
1395
+ """Resolve an estimator spec to a constructor/factory.
1396
+
1397
+ ``estimator_name`` can be:
1398
+ - a built-in method name: ``"ols"``, ``"nls_lmfit"``, ``"nls_eviews"``;
1399
+ - a key in ``estimator_classes`` whose value is either a class or a
1400
+ callable factory;
1401
+ - a callable/factory directly, e.g. ``Estimate_nls.with_defaults(...)``.
1402
+
1403
+ The resolved object is only required to be callable here. After it is
1404
+ called with the equation, the returned object is validated with
1405
+ ``isinstance(result, modelestimator_new.EstimatorBackend)``.
1406
+ """
1407
+ if estimator_name is None or estimator_name is False:
1408
+ raise ModelSpecificationError("Estimator flag was present, but no estimator method was supplied")
1409
+
1410
+ if callable(estimator_name) and not isinstance(estimator_name, str):
1411
+ return estimator_name
1412
+
1413
+ name = str(estimator_name).strip().lower()
1414
+ if not name:
1415
+ raise ModelSpecificationError("Estimator flag was present, but no estimator method was supplied")
1416
+
1417
+ if estimator_classes:
1418
+ # Accept both exact and lower-case keys, so {'LS': ls} and {'ls': ls}
1419
+ # both work after clean_expressions() has upper-cased the DSL.
1420
+ if name in estimator_classes:
1421
+ return estimator_classes[name]
1422
+ for key, value in estimator_classes.items():
1423
+ if str(key).strip().lower() == name:
1424
+ return value
1425
+
1426
+ class_names = {
1427
+ 'ols': 'Estimate_ols',
1428
+ 'nls_lmfit': 'Estimate_nls_lmfit',
1429
+ 'nls_eviews': 'Estimate_nls_eviews',
1430
+ }
1431
+ if name not in class_names:
1432
+ allowed = ', '.join(sorted(class_names))
1433
+ raise ModelSpecificationError(
1434
+ f"Unknown estimator={estimator_name!r}. Expected one of: {allowed}, "
1435
+ "a callable estimator=..., or pass estimator_classes={name: class_or_factory}."
1436
+ )
1437
+
1438
+ try:
1439
+ import modelestimator_new as me
1440
+ except ImportError as exc:
1441
+ raise ModelSpecificationError(
1442
+ "Equations with <estimator=...> require modelestimator_new to be importable."
1443
+ ) from exc
1444
+
1445
+ try:
1446
+ return getattr(me, class_names[name])
1447
+ except AttributeError as exc:
1448
+ raise ModelSpecificationError(
1449
+ f"modelestimator_new has no class {class_names[name]!r} for estimator={name!r}"
1450
+ ) from exc
1451
+
1452
+
1453
+ def _maybe_run_estimator_fit(estimator_obj):
1454
+ """Call the first standard fit/estimate/run method available, if any."""
1455
+ for method_name in ('fit', 'estimate', 'run'):
1456
+ method = getattr(estimator_obj, method_name, None)
1457
+ if callable(method):
1458
+ return method()
1459
+ return None
1460
+
1461
+
1462
+ def _clean_estimated_expression(candidate: str) -> str:
1463
+ """Turn either an expression or a one-FRML string into a bare expression."""
1464
+ text = str(candidate).strip()
1465
+ if not text:
1466
+ return text
1467
+ if text.upper().lstrip().startswith('FRML '):
1468
+ return split_frml_reqopts(text).expression
1469
+ return text.rstrip('$').strip()
1470
+
1471
+
1472
+ def _extract_expression_from_estimator(estimator_obj, fit_result=None) -> Optional[str]:
1473
+ """Extract the baked equation from an estimator backend.
1474
+
1475
+ EstimatorBackend and its subclasses always produce ``org_eq_baked``,
1476
+ which contains the equation with estimated coefficients substituted.
1477
+
1478
+ For custom backends that lack ``org_eq_baked``, falls back to method
1479
+ calls (``to_expression()``, ``to_equation()``, ``to_frml()``).
1480
+ """
1481
+ # Try the primary source: EstimatorBackend.org_eq_baked
1482
+ baked = getattr(estimator_obj, 'org_eq_baked', None)
1483
+ if isinstance(baked, str) and baked.strip():
1484
+ return _clean_estimated_expression(baked)
1485
+
1486
+ # Fallback for custom backends: try method calls
1487
+ method_names = ('to_expression', 'to_equation', 'to_frml')
1488
+ for obj in (fit_result, estimator_obj):
1489
+ if obj is None:
1490
+ continue
1491
+ for name in method_names:
1492
+ method = getattr(obj, name, None)
1493
+ if callable(method):
1494
+ value = method()
1495
+ if isinstance(value, str) and value.strip():
1496
+ return _clean_estimated_expression(value)
1497
+ return None
1498
+
1499
+
1500
+ def _value_from_param(value):
1501
+ """Extract a scalar from lmfit/statsmodels/pandas/numpy parameter values."""
1502
+ if hasattr(value, 'value'):
1503
+ value = value.value
1504
+ if hasattr(value, 'item'):
1505
+ try:
1506
+ value = value.item()
1507
+ except Exception:
1508
+ pass
1509
+ return value
1510
+
1511
+
1512
+ def _dict_from_params(params) -> dict:
1513
+ """Convert the parameter containers used by modelestimator_new to a plain dict.
1514
+
1515
+ Handles dict / pandas Series / statsmodels params, lmfit.Parameters
1516
+ via valuesdict(), and lmfit Parameter values via .value.
1517
+ """
1518
+ if params is None:
1519
+ return {}
1520
+ if hasattr(params, 'valuesdict'):
1521
+ params = params.valuesdict()
1522
+ elif hasattr(params, 'to_dict'):
1523
+ params = params.to_dict()
1524
+ if hasattr(params, 'items'):
1525
+ out = {}
1526
+ for k, v in params.items():
1527
+ try:
1528
+ out[str(k).upper()] = float(_value_from_param(v))
1529
+ except Exception:
1530
+ out[str(k).upper()] = _value_from_param(v)
1531
+ return out
1532
+ return {}
1533
+
1534
+
1535
+ def _extract_params_from_estimator(estimator_obj, fit_result=None) -> dict:
1536
+ """Try common locations for estimated parameters."""
1537
+ params = {}
1538
+ direct_names = (
1539
+ # modelestimator_new.EstimatorBackend contract: every backend stores
1540
+ # coefficients here, keyed as C__1, C__2, ...
1541
+ 'coef_estimate_dict',
1542
+ 'coef_ser',
1543
+ 'estimated_params',
1544
+ 'params',
1545
+ 'param',
1546
+ 'coefficients',
1547
+ 'coef',
1548
+ 'coefs',
1549
+ 'best_values',
1550
+ )
1551
+ nested_names = (
1552
+ 'result',
1553
+ 'results',
1554
+ 'res',
1555
+ 'fit_result',
1556
+ 'estimation_result',
1557
+ # modelestimator_new keeps the native fitted result here.
1558
+ 'regression_model',
1559
+ )
1560
+
1561
+ for obj in (fit_result, estimator_obj):
1562
+ if obj is None:
1563
+ continue
1564
+ for name in direct_names:
1565
+ params.update(_dict_from_params(getattr(obj, name, None)))
1566
+ for nested_name in nested_names:
1567
+ nested = getattr(obj, nested_name, None)
1568
+ if nested is None:
1569
+ continue
1570
+ for name in direct_names:
1571
+ params.update(_dict_from_params(getattr(nested, name, None)))
1572
+
1573
+ return params
1574
+
1575
+
1576
+ def _instantiate_estimator(estimator_constructor, expression: str, kwargs: dict):
1577
+ """Instantiate a class or local factory with the equation.
1578
+
1579
+ modelestimator_new.with_defaults factories accept either ``org_eq=...`` or
1580
+ the equation as the first positional argument. Custom local factories often
1581
+ choose the positional style, so we support both.
1582
+ """
1583
+ if not callable(estimator_constructor):
1584
+ raise ModelSpecificationError(
1585
+ f"Estimator {estimator_constructor!r} is not callable. "
1586
+ "Use an estimator class, an estimator factory such as "
1587
+ "Estimate_nls.with_defaults(...), or a local callable returning "
1588
+ "an EstimatorBackend instance."
1589
+ )
1590
+ try:
1591
+ return estimator_constructor(org_eq=expression, **kwargs)
1592
+ except TypeError as first_exc:
1593
+ try:
1594
+ return estimator_constructor(expression, **kwargs)
1595
+ except TypeError:
1596
+ raise first_exc
1597
+
1598
+
1599
+ def _require_estimator_backend_instance(estimator_obj, estimator_spec):
1600
+ """Validate the instantiated estimator against the shared backend contract.
1601
+
1602
+ A local estimator specification such as ``Estimate_nls.with_defaults(...)``
1603
+ is a callable factory, not an EstimatorBackend instance. Therefore we first
1604
+ instantiate/call it with the equation, and only then require that the result
1605
+ is an instance of modelestimator_new.EstimatorBackend.
1606
+ """
1607
+ try:
1608
+ from modelestimator_new import EstimatorBackend
1609
+ except ImportError as exc:
1610
+ raise ModelSpecificationError(
1611
+ "Equations with <estimator=...> require modelestimator_new "
1612
+ "to be importable so returned estimator objects can be checked "
1613
+ "against EstimatorBackend."
1614
+ ) from exc
1615
+
1616
+ if not isinstance(estimator_obj, EstimatorBackend):
1617
+ raise ModelSpecificationError(
1618
+ f"Estimator {_estimator_display_name(estimator_spec)!r} returned "
1619
+ f"{type(estimator_obj).__name__}, but it must return an "
1620
+ "instance of modelestimator_new.EstimatorBackend. "
1621
+ "Use Estimate_ols, Estimate_nls_lmfit, Estimate_nls_eviews, "
1622
+ "Estimate_nls.with_defaults(...), or a custom subclass of "
1623
+ "EstimatorBackend."
1624
+ )
1625
+ return estimator_obj
1626
+
1627
+
1628
+ def _estimate_and_bake_expression(
1629
+ expression: str,
1630
+ *,
1631
+ estimator_name,
1632
+ input_df=None,
1633
+ smpl=None,
1634
+ caption: Optional[str] = None,
1635
+ estimator_kwargs: Optional[dict] = None,
1636
+ estimator_classes: Optional[dict] = None,
1637
+ ):
1638
+ """Instantiate an estimator/factory, run it, and return (baked_expression, estimator_obj)."""
1639
+ estimator_constructor = _get_estimator_class(estimator_name, estimator_classes)
1640
+ kwargs = dict(estimator_kwargs or {})
1641
+
1642
+ # Do not pass None defaults into a local with_defaults factory; it may
1643
+ # already carry input_df/smpl/var_description. Explicit constructor kwargs
1644
+ # still override the factory's stored defaults.
1645
+ if input_df is not None and 'input_df' not in kwargs:
1646
+ kwargs['input_df'] = input_df
1647
+ if smpl is not None and 'smpl' not in kwargs:
1648
+ kwargs['smpl'] = smpl
1649
+ if caption is not None and 'caption' not in kwargs:
1650
+ kwargs['caption'] = caption
1651
+
1652
+ estimator_obj = _instantiate_estimator(estimator_constructor, expression, kwargs)
1653
+ estimator_obj = _require_estimator_backend_instance(estimator_obj, estimator_name)
1654
+ fit_result = _maybe_run_estimator_fit(estimator_obj)
1655
+
1656
+ baked = _extract_expression_from_estimator(estimator_obj, fit_result)
1657
+ if not baked:
1658
+ raise ModelSpecificationError(
1659
+ f"Estimator {estimator_obj.__class__.__name__} must expose the baked "
1660
+ f"(coefficient-substituted) equation through 'org_eq_baked' attribute "
1661
+ f"or 'to_expression()/to_equation()/to_frml()' method."
1662
+ )
1663
+ return baked, estimator_obj
1664
+
1665
+
1666
+
1667
+ def _markdown_escape_cell(value) -> str:
1668
+ """Render a value safely inside a simple Markdown table cell."""
1669
+ if value is None:
1670
+ text = ""
1671
+ else:
1672
+ text = str(value)
1673
+ return text.replace("\n", "<br>").replace("|", r"\|")
1674
+
1675
+
1676
+ def _markdown_format_number(value) -> str:
1677
+ """Compact numeric formatting for estimation markdown tables."""
1678
+ try:
1679
+ return f"{float(value):.10g}"
1680
+ except Exception:
1681
+ return str(value)
1682
+
1683
+
1684
+ def _estimator_coefficients_for_markdown(estimator_obj) -> dict:
1685
+ """Return coefficients from an EstimatorBackend-like object as a dict."""
1686
+ coefs = getattr(estimator_obj, "coef_estimate_dict", None)
1687
+ if hasattr(coefs, "to_dict"):
1688
+ coefs = coefs.to_dict()
1689
+ if isinstance(coefs, dict):
1690
+ return coefs
1691
+ coef_ser = getattr(estimator_obj, "coef_ser", None)
1692
+ if hasattr(coef_ser, "to_dict"):
1693
+ return coef_ser.to_dict()
1694
+ return _extract_params_from_estimator(estimator_obj)
1695
+
1696
+
1697
+ def _estimation_record_to_markdown(record: dict) -> str:
1698
+ """Create a compact Markdown report block for one Makemodel estimation."""
1699
+ est = record.get("estimator_object")
1700
+ estimator_name = record.get("estimator") or type(est).__name__
1701
+ requested_smpl = record.get("smpl", "")
1702
+ try:
1703
+ effective_smpl = getattr(est, "estimation_smpl")
1704
+ except Exception:
1705
+ effective_smpl = getattr(est, "_effective_smpl", "")
1706
+
1707
+ original = record.get("original_expression") or getattr(est, "org_eq", "")
1708
+ baked = record.get("baked_expression") or getattr(est, "org_eq_baked", "")
1709
+
1710
+ summary_rows = [
1711
+ ("Estimator", estimator_name),
1712
+ ("Requested sample", requested_smpl),
1713
+ ("Effective sample", effective_smpl),
1714
+ ("Original equation", original),
1715
+ ("Estimated equation", baked),
1716
+ ]
1717
+
1718
+ regression_model = getattr(est, "regression_model", None)
1719
+ for attr in ("success", "message", "chisqr", "redchi", "aic", "bic"):
1720
+ if regression_model is not None and hasattr(regression_model, attr):
1721
+ value = getattr(regression_model, attr)
1722
+ if attr in {"chisqr", "redchi", "aic", "bic"}:
1723
+ value = _markdown_format_number(value)
1724
+ summary_rows.append((attr, value))
1725
+
1726
+ try:
1727
+ if regression_model is not None and hasattr(regression_model, "rsquared"):
1728
+ summary_rows.append(("R-squared", _markdown_format_number(regression_model.rsquared)))
1729
+ if regression_model is not None and hasattr(regression_model, "rsquared_adj"):
1730
+ summary_rows.append(("Adj. R-squared", _markdown_format_number(regression_model.rsquared_adj)))
1731
+ except Exception:
1732
+ pass
1733
+
1734
+ lines = [
1735
+ "",
1736
+ "**Estimation output**",
1737
+ "",
1738
+ "| Item | Value |",
1739
+ "|:--|:--|",
1740
+ ]
1741
+ lines.extend(
1742
+ f"| {_markdown_escape_cell(k)} | {_markdown_escape_cell(v)} |"
1743
+ for k, v in summary_rows
1744
+ if v not in (None, "")
1745
+ )
1746
+
1747
+ coefs = _estimator_coefficients_for_markdown(est)
1748
+ if coefs:
1749
+ lines.extend([
1750
+ "",
1751
+ "| Parameter | Estimate |",
1752
+ "|:--|--:|",
1753
+ ])
1754
+ for key in sorted(coefs, key=lambda x: (str(x).split("__")[0], int(str(x).split("__")[-1]) if str(x).split("__")[-1].lstrip("-").isdigit() else str(x))):
1755
+ lines.append(
1756
+ f"| {_markdown_escape_cell(key)} | {_markdown_escape_cell(_markdown_format_number(coefs[key]))} |"
1757
+ )
1758
+
1759
+ return "\n".join(lines) + "\n"
1760
+
1761
+
1762
+ def _line_has_estimator_tag(line: str) -> bool:
1763
+ """True when a source line appears to contain an estimator/est flag."""
1764
+ return bool(re.search(r"<[^>]*\b(?:estimator|est)\b[^>]*>", line, flags=re.IGNORECASE))
1765
+
1766
+
1767
+ def _markdown_with_estimation_blocks(original_text: str, estimation_records: list[dict]) -> str:
1768
+ """Insert estimation markdown blocks after estimator-tagged source lines.
1769
+
1770
+ This preserves the original user-facing Markdown as much as possible. For
1771
+ the common notebook syntax, each line beginning with ``>`` and containing
1772
+ ``<estimator=...>`` gets the next estimation block inserted immediately
1773
+ after it. If template expansion creates more estimated equations than can
1774
+ be matched to source lines, the remaining blocks are appended at the end.
1775
+ """
1776
+ records = list(estimation_records or [])
1777
+ if not records:
1778
+ return original_text
1779
+
1780
+ out = []
1781
+ rec_i = 0
1782
+ for line in original_text.splitlines():
1783
+ out.append(line)
1784
+ if rec_i < len(records) and _line_has_estimator_tag(line):
1785
+ out.append(_estimation_record_to_markdown(records[rec_i]).rstrip())
1786
+ rec_i += 1
1787
+
1788
+ if rec_i < len(records):
1789
+ if out and out[-1].strip():
1790
+ out.append("")
1791
+ out.append("## Estimation output")
1792
+ for rec in records[rec_i:]:
1793
+ out.append(_estimation_record_to_markdown(rec).rstrip())
1794
+
1795
+ return "\n".join(out)
1796
+
1797
+
1798
+ @dataclass
1799
+ class BaseExplode:
1800
+ """Common parent for Makemodel and Listmodels."""
1801
+ original_statements : str = field(default="", metadata={"description": "Input expressions"})
1802
+ normal_frml : str = field(default="",init=False, metadata={"description": "Output normalized expressions"})
1803
+ markdown_model : str = field(default="",init=False, metadata={"description": "As markdown"})
1804
+ funks : List[Any] = field(default_factory=list, metadata={"description": "List of user specified functions to be used in model"})
1805
+ var_description: dict = field(default_factory=dict, metadata={"description": "Variable descriptions"})
1806
+
1807
+
1808
+ @property
1809
+ def show(self):
1810
+ print (self.normal_frml.strip())
1811
+
1812
+ @property
1813
+ def draw(self):
1814
+ self.mmodel.drawmodel()
1815
+
1816
+ @cached_property
1817
+ def mmodel(self):
1818
+ from modelclass import model
1819
+ return model(self.normal_frml,funks=self.funks,var_description=self.var_description)
1820
+
1821
+ def __str__(self):
1822
+ return self.normal_frml.strip()
1823
+
1824
+ @property
1825
+ def render(self):
1826
+ # display(Markdown(self.markdown_model))
1827
+
1828
+ rendered_md = render_markdown_model(self.markdown_model)
1829
+ display_mixed_markdown(rendered_md)
1830
+ @property
1831
+ def render_est(self):
1832
+ # display(Markdown(self.markdown_model))
1833
+
1834
+ rendered_md = render_markdown_model(self.markdown_model_with_estimation)
1835
+ display_mixed_markdown(rendered_md)
1836
+
1837
+
1838
+ def _field_is_showable(self, f) -> bool:
1839
+ """Dataclass fields are showable unless metadata says otherwise."""
1840
+ return f.metadata.get("showable", True) is not False
1841
+
1842
+
1843
+ def _explicit_show_properties(self):
1844
+ """Return explicit @property/@cached_property names starting with 'show'."""
1845
+
1846
+ result = []
1847
+
1848
+ for name, obj in inspect.getmembers_static(type(self)):
1849
+ if not name.startswith("show"):
1850
+ continue
1851
+
1852
+ if isinstance(obj, property):
1853
+ desc_lines = (obj.__doc__ or "").strip().splitlines()
1854
+ desc = desc_lines[0] if desc_lines else ""
1855
+ result.append((name, desc))
1856
+
1857
+ elif isinstance(obj, functools.cached_property):
1858
+ desc_lines = (getattr(obj.func, "__doc__", "") or "").strip().splitlines()
1859
+ desc = desc_lines[0] if desc_lines else ""
1860
+ result.append((name, desc))
1861
+
1862
+ return result
1863
+
1864
+
1865
+ def __getattr__(self, attr):
1866
+ if attr.startswith("show"):
1867
+ prop = attr[4:].lower()
1868
+ field_defs = fields(self)
1869
+ field_by_name = {f.name: f for f in field_defs}
1870
+
1871
+ if prop in field_by_name and self._field_is_showable(field_by_name[prop]):
1872
+ value = getattr(self, prop)
1873
+
1874
+ if isinstance(value, list) and all(isinstance(v, nz.Normalized_frml) for v in value):
1875
+ print(f"\n--- {prop.upper()} ({len(value)} items) ---")
1876
+ for i, frml in enumerate(value, 1):
1877
+ print(f"\n[{i}]")
1878
+ print(frml)
1879
+ else:
1880
+ print(f"{prop.capitalize()}: \n{value}")
1881
+
1882
+ return None
1883
+
1884
+ import inspect
1885
+
1886
+ varname = self.__class__.__name__.lower()
1887
+ frame = inspect.currentframe()
1888
+ try:
1889
+ caller = frame.f_back if frame else None
1890
+ if caller:
1891
+ varname = next(
1892
+ (k for k, v in caller.f_locals.items() if v is self),
1893
+ varname,
1894
+ )
1895
+ finally:
1896
+ del frame
1897
+ try:
1898
+ del caller
1899
+ except NameError:
1900
+ pass
1901
+
1902
+ showable_fields = [
1903
+ f for f in field_defs
1904
+ if self._field_is_showable(f)
1905
+ ]
1906
+
1907
+ field_items = [
1908
+ (
1909
+ f"show{f.name}",
1910
+ f.metadata.get("description", "")
1911
+ )
1912
+ for f in showable_fields
1913
+ ]
1914
+
1915
+ property_items = self._explicit_show_properties()
1916
+
1917
+ items_by_name = {}
1918
+
1919
+ for name, desc in field_items:
1920
+ items_by_name[name] = desc
1921
+
1922
+ for name, desc in property_items:
1923
+ items_by_name[name] = desc
1924
+
1925
+ show_items = sorted(items_by_name.items())
1926
+
1927
+ left_parts = [f"{varname}.{name}" for name, _ in show_items]
1928
+ maxlen = max((len(lp) for lp in left_parts), default=0)
1929
+
1930
+ options = "\n".join(
1931
+ f"{lp.ljust(maxlen)} # {desc}" if desc else lp
1932
+ for lp, (_, desc) in zip(left_parts, show_items)
1933
+ )
1934
+
1935
+ print(f"No such property '{prop}'. Try one of:\n{options}")
1936
+ return None
1937
+
1938
+ raise AttributeError(f"'{type(self).__name__}' object has no attribute '{attr}'")
1939
+
1940
+ @dataclass
1941
+ class Makemodel(BaseExplode):
1942
+ # original_statements : str = field(default="", metadata={"description": "Input expressions"})
1943
+ # normal_frml : str = field(default="", metadata={"description": "Output normalized expressions"})
1944
+
1945
+ normal_main : str = field(init=False, metadata={"description": "Normalized frmls"})
1946
+ normal_fit : str = field(init=False, metadata={"description": "Normalized frmls for fitted values"})
1947
+ normal_calc_add : str = field(init=False, metadata={"description": "Normalized frmls to calculate add factors"})
1948
+ replacements : Union[tuple[str, str], list[tuple[str, str]]] = field(default_factory=list, metadata={"description": "list of string tupels with string replacements"})
1949
+ list_defs : str = field(default="", metadata={"description": "Lists definitions"})
1950
+ modelname : str = field(default="", metadata={"description": "A optional name for this (sub) model"})
1951
+
1952
+ clean_frml_statements : str = field(init=False, metadata={"description": "With frml and nice lists"})
1953
+ post_doable : str = field(init=False, metadata={"description": "Expanded after doable"})
1954
+ post_do : str = field(init=False, metadata={"description": "Frmls after do expansion"})
1955
+ post_sum : str = field(init=False, metadata={"description": "Frmls after expanding sums"})
1956
+ expanded_frml : str = field(init=False, metadata={"description": "frmls after expanding "})
1957
+ normal_expressions : List[Any] = field(init=False, metadata={"description": "List of normal expressions"})
1958
+ list_specification : str = field(init=False, metadata={"description": "All list specifications in string "})
1959
+ modellist : str = field(init=False, metadata={"description": "The lists defined in string as a dictionary"})
1960
+ type_input : str = field(default= 'markdown' ,metadata={"description": "Originaal as type modelflow or markdown"})
1961
+ # Optional estimation. An equation is estimated only if its FRML-name
1962
+ # contains <estimator=...>/<est=...> or <estimator>/<est>. If the flag has no value,
1963
+ # the global estimator below is used. Untagged equations remain identities.
1964
+ input_df : Optional[Any] = field(default=None, metadata={"description": "DataFrame used when tagged equations are estimated"})
1965
+ estimator : Optional[Any] = field(default=None, metadata={"description": "Default estimator for tagged equations: method name or callable factory"})
1966
+ smpl : Any = field(default=None, metadata={"description": "Default estimation sample; equation <smpl=start end> overrides it"})
1967
+ estimator_kwargs : dict = field(default_factory=dict, metadata={"description": "Shared kwargs passed to estimator constructors"})
1968
+ estimator_classes : Optional[dict] = field(default=None, metadata={"description": "Optional method-name to estimator-class mapping"})
1969
+ estimator_namespace : Optional[dict] = field(default=None, metadata={"description": "Optional namespace for resolving <estimator=name>; defaults to caller locals/globals"})
1970
+ estimation_records : List[Any] = field(init=False, metadata={"description": "Information about equations estimated during construction"})
1971
+ normal_input_expressions : List[str] = field(init=False, metadata={"description": "Expressions sent to modelnormalize, after optional estimation"})
1972
+ normal_output_frmlnames : List[str] = field(init=False, metadata={"description": "FRML names emitted; estimation flags are preserved"})
1973
+
1974
+ def __post_init__(self):
1975
+ '''prepares a model from a model template.
1976
+
1977
+ Returns a expanded model which is ready to solve.
1978
+
1979
+ If an expanded equation has <estimator=...>/<est=...> (or <estimator>/<est> plus a
1980
+ global self.estimator), the estimator is run here and the estimated
1981
+ coefficients are baked into the expression before modelnormalize sees it.
1982
+ Untagged equations are treated exactly as before.
1983
+ '''
1984
+
1985
+ if self.type_input == 'markdown':
1986
+ extracted_frml = extract_model_from_markdown(self.list_defs+self.original_statements)
1987
+ self.markdown_model = (mfmod_list_to_codeblock(self.original_statements))
1988
+
1989
+ else:
1990
+ extracted_frml = self.list_defs+self.original_statements
1991
+ self.markdown_model = ''
1992
+
1993
+ self.clean_frml_statements = clean_expressions(
1994
+ apply_replacements(extracted_frml,self.replacements)
1995
+ ).strip().upper()
1996
+ # debug_var(extract_model_from_markdown(self.original_statements))
1997
+
1998
+
1999
+ self.post_doable = doable_unroll(self.clean_frml_statements ,self.funks)
2000
+ # assert 1==2
2001
+ self.post_do = dounloop(self.post_doable) # we unroll the do loops
2002
+
2003
+ self.modellist = list_extract(self.post_do)
2004
+ self.post_sum = sumunroll(self.post_do,listin=self.modellist) # then we unroll the sum
2005
+
2006
+ self.expanded_frml = self.post_sum
2007
+ self.expanded_frml_list = find_frml(self.expanded_frml)
2008
+ self.expanded_frml_split = [split_frml_reqopts(frml) for frml in self.expanded_frml_list]
2009
+
2010
+ self.estimation_records = []
2011
+ self.normal_input_expressions = []
2012
+ self.normal_output_frmlnames = []
2013
+
2014
+ # Cache the estimator flag per equation so we evaluate kw_frml_name
2015
+ # exactly once per equation instead of three times.
2016
+ estimator_flags_per_equation = [
2017
+ _frml_option_value(parts.frmlname, 'ESTIMATOR', default=None)
2018
+ for parts in self.expanded_frml_split
2019
+ ]
2020
+ has_estimated_equations = any(flag is not None for flag in estimator_flags_per_equation)
2021
+
2022
+ # Resolve the estimator namespace lazily: only walk frames when we
2023
+ # actually need to look up estimator names AND the caller did not
2024
+ # already supply a namespace. Walking f_globals/f_locals on every
2025
+ # Makemodel(...) call is expensive when no estimation is involved
2026
+ # (the magic always passes user_ns explicitly, so the walk would be
2027
+ # pure overhead in that path).
2028
+ if has_estimated_equations and self.estimator_namespace is None and not self.estimator_classes:
2029
+ namespace_for_resolution = _caller_estimator_namespace()
2030
+ else:
2031
+ namespace_for_resolution = self.estimator_namespace or {}
2032
+
2033
+ # Allow notebook-local factories such as:
2034
+ # ls = Estimate_nls.with_defaults(...)
2035
+ # Makemodel('><estimator=ls> ...') or Makemodel('><est=ls> ...')
2036
+ # Explicit estimator_classes still wins over caller-scope names.
2037
+ self.estimator_classes = _merge_estimator_namespaces(
2038
+ explicit=self.estimator_classes,
2039
+ namespace=namespace_for_resolution,
2040
+ )
2041
+
2042
+ if has_estimated_equations and self.input_df is None and self.estimator is None:
2043
+ # String estimators such as <estimator=nls_lmfit> or <est=nls_lmfit> need input_df
2044
+ # here. A callable/factory estimator may already have input_df
2045
+ # captured through Estimate_nls.with_defaults(...), so that case
2046
+ # is allowed when self.estimator is supplied.
2047
+ explicit_estimators = [flag for flag in estimator_flags_per_equation if flag is not None]
2048
+ if not any(
2049
+ isinstance(flag, str)
2050
+ and self.estimator_classes
2051
+ and str(flag).strip().lower() in {str(k).strip().lower() for k in self.estimator_classes}
2052
+ for flag in explicit_estimators
2053
+ ):
2054
+ raise ModelSpecificationError(
2055
+ "input_df is required when any equation has an <estimator=...> or <est=...> flag, "
2056
+ "unless you pass a callable/factory estimator that already carries input_df"
2057
+ )
2058
+
2059
+ # def normal(ind_o,the_endo='',add_add_factor=True,do_preprocess = True,add_suffix = '_A',endo_lhs = True, =False,make_fitted=False,eviews=''):
2060
+ self.normal = []
2061
+ for equation_index, parts in enumerate(self.expanded_frml_split):
2062
+ expression_for_normal = self._expression_after_optional_estimation(
2063
+ parts,
2064
+ equation_index=equation_index,
2065
+ estimator_flag=estimator_flags_per_equation[equation_index],
2066
+ )
2067
+ if kw_frml_name(parts.frmlname, 'DROP'):
2068
+ continue
2069
+
2070
+ self.normal_input_expressions.append(expression_for_normal)
2071
+ self.normal_output_frmlnames.append(parts.frmlname)
2072
+
2073
+ self.normal.append((
2074
+ parts,
2075
+ nz.normal(expression_for_normal,
2076
+ add_add_factor= kw_frml_name(parts.frmlname, 'ADD') or kw_frml_name(parts.frmlname, 'STOC'),
2077
+ add_suffix = kw_frml_name(parts.frmlname, 'ADD_SUFFIX','_A'),
2078
+ make_fixable = kw_frml_name(parts.frmlname, 'EXO')or kw_frml_name(parts.frmlname, 'STOC'),
2079
+ make_fitted = kw_frml_name(parts.frmlname, 'FIT'),
2080
+ the_endo = kw_frml_name(parts.frmlname, 'ENDO'),
2081
+ endo_lhs = False if 'FALSE' == kw_frml_name(parts.frmlname, 'ENDO_LHS',default='1') else True,
2082
+ implicit = kw_frml_name(parts.frmlname, 'IMPLICIT')
2083
+ )
2084
+ ))
2085
+
2086
+ self.normal_expressions = [n for p,n in self.normal ]
2087
+
2088
+ # udrullet = lagarray_unroll(udrullet,funks=funks )
2089
+ # udrullet = creatematrix(udrullet,listin=modellist)
2090
+ # udrullet = createarray(udrullet,listin=modellist)
2091
+ # udrullet = argunroll(udrullet,listin=modellist)
2092
+ self.normal_main = '\n'.join([f'FRML {frmlname} {normal.normalized} $'
2093
+ for frmlname, (parts, normal)
2094
+ in zip(self.normal_output_frmlnames, self.normal)
2095
+ ])
2096
+ self.normal_fit = '\n'.join([f'FRML <FIT> {normal.fitted } $ '
2097
+ for parts, normal in self.normal if len(normal.fitted)
2098
+ ])
2099
+ self.normal_calc_add = '\n'.join([f'FRML <CALC_ADD_FACTOR> {normal.calc_add_factor } $ '
2100
+ for parts, normal in self.normal if len(normal.calc_add_factor)
2101
+ ])
2102
+
2103
+ self.normal_frml = '\n'.join( [self.normal_main,self.normal_fit,self.normal_calc_add])
2104
+
2105
+ # Merge var_description from estimated equations; user-supplied takes precedence.
2106
+ estimated_var_desc = {}
2107
+ for record in self.estimation_records:
2108
+ obj = record.get('estimator_object')
2109
+ if obj is not None and hasattr(obj, 'var_description'):
2110
+ estimated_var_desc |= obj.var_description
2111
+ self.var_description = estimated_var_desc | self.var_description
2112
+
2113
+ check_syntax_model(self.normal_frml)
2114
+
2115
+ self.list_specification = self.get_lists()
2116
+ return
2117
+
2118
+ def _expression_after_optional_estimation(self, parts, *, equation_index: Optional[int] = None,
2119
+ estimator_flag: Any = ...) -> str:
2120
+ """Return parts.expression, or an estimated/baked version when tagged.
2121
+
2122
+ ``estimator_flag`` is the cached value from ``__post_init__``'s single
2123
+ pass over ``expanded_frml_split``. It may be passed as ``...`` (the
2124
+ default sentinel) for backward compatibility, in which case the flag
2125
+ is resolved here. Direct callers from the new code path should always
2126
+ pass the cached value to avoid redundant ``kw_frml_name`` work.
2127
+ """
2128
+ if estimator_flag is ...:
2129
+ estimator_flag = _frml_option_value(parts.frmlname, 'ESTIMATOR', default=None)
2130
+ if estimator_flag is None:
2131
+ return parts.expression
2132
+
2133
+ if isinstance(estimator_flag, str) and estimator_flag.strip().upper() in {'0', 'FALSE', 'NO', 'NONE'}:
2134
+ return parts.expression
2135
+
2136
+ estimator_name = self.estimator if estimator_flag is True or estimator_flag == '' else estimator_flag
2137
+ if not estimator_name:
2138
+ raise ModelSpecificationError(
2139
+ f"{parts.expression!r} has <estimator> but no estimator method was supplied"
2140
+ )
2141
+
2142
+ local_smpl = _frml_option_value(parts.frmlname, 'SMPL', default=None)
2143
+ local_caption = _frml_option_value(parts.frmlname, 'CAPTION', default=None)
2144
+ local_constraints = _frml_option_value(parts.frmlname, 'CONSTRAINTS', default=None)
2145
+
2146
+ # Equation-local smpl must override the estimator factory default.
2147
+ # If Makemodel.input_df is None because the dataframe is captured in a
2148
+ # local factory such as ``ls = Estimate_nls.with_defaults(input_df=npl)``,
2149
+ # read the factory's _estimator_defaults metadata before parsing
2150
+ # ``smpl=2013 2018``. This avoids constructing a dummy estimator,
2151
+ # which would run a real fit in EstimatorBackend.__post_init__.
2152
+ df_for_smpl = self.input_df
2153
+ if df_for_smpl is None:
2154
+ df_for_smpl = _infer_input_df_from_estimator_spec(
2155
+ estimator_name,
2156
+ self.estimator_classes,
2157
+ )
2158
+
2159
+ smpl = _parse_smpl(
2160
+ local_smpl if local_smpl is not None else self.smpl,
2161
+ df=df_for_smpl,
2162
+ )
2163
+
2164
+ # FRML-attribute constraints are appended as a synthetic ST. clause so
2165
+ # the estimator's single parsing path handles both inline and attribute
2166
+ # forms uniformly.
2167
+ expression_to_estimate = parts.expression
2168
+ if local_constraints:
2169
+ expression_to_estimate = (
2170
+ f"{expression_to_estimate.rstrip(' $')} ST. {local_constraints}"
2171
+ )
2172
+
2173
+ baked_expression, estimator_obj = _estimate_and_bake_expression(
2174
+ expression_to_estimate,
2175
+ estimator_name=estimator_name,
2176
+ input_df=self.input_df,
2177
+ smpl=smpl,
2178
+ caption=local_caption,
2179
+ estimator_kwargs=self.estimator_kwargs,
2180
+ estimator_classes=self.estimator_classes,
2181
+ )
2182
+ self.estimation_records.append({
2183
+ 'equation_index': equation_index,
2184
+ 'frmlname': parts.frmlname,
2185
+ 'output_frmlname': parts.frmlname,
2186
+ 'estimator': _estimator_display_name(estimator_name),
2187
+ 'smpl': smpl,
2188
+ 'original_expression': parts.expression,
2189
+ 'baked_expression': baked_expression,
2190
+ 'estimator_object': estimator_obj,
2191
+ })
2192
+ return baked_expression
2193
+
2194
+ def estimation_report(
2195
+ self,
2196
+ path: str = "html",
2197
+ filename: str = "makemodel_estimation_report.html",
2198
+ plot_format: str = "svg",
2199
+ title: str = "Makemodel Estimation Summary",
2200
+ open_file: bool = False,
2201
+ report_all: bool = False,
2202
+ ) -> None:
2203
+ """Export an HTML report for estimations embedded in this Makemodel.
2204
+
2205
+ The report reuses :func:`modelestimator_new.export_estimation_reports_to_html`.
2206
+ By default, only equations tagged with ``<estimator=...>`` are included.
2207
+ With ``report_all=True``, unestimated identity equations are included as
2208
+ minimal panels alongside the full estimation reports.
2209
+ """
2210
+ return export_makemodel_estimation_reports_to_html(
2211
+ self,
2212
+ path=path,
2213
+ filename=filename,
2214
+ plot_format=plot_format,
2215
+ title=title,
2216
+ open_file=open_file,
2217
+ report_all=report_all,
2218
+ )
2219
+
2220
+ def report(
2221
+ self,
2222
+ title: str = "",
2223
+ plot_format: str = "svg",
2224
+ report_all: bool = False,
2225
+ ) -> "MakeModelReport":
2226
+ """Return a :class:`~modelhtml.MakeModelReport` for this model.
2227
+
2228
+ The report renders the full model documentation (prose + equations)
2229
+ with a collapsible TOC and inline collapsible estimation panels.
2230
+
2231
+ Parameters
2232
+ ----------
2233
+ title : str, optional
2234
+ Report title. Defaults to ``modelname`` or ``"Model Report"``.
2235
+ plot_format : {"svg", "png"}, default ``"svg"``
2236
+ Embedded plot format.
2237
+ report_all : bool, default ``False``
2238
+ Include identity equations as minimal panels.
2239
+
2240
+ Examples
2241
+ --------
2242
+ >>> mm.report().save() # → html/model-report_report.html
2243
+ >>> mm.report(title="My Model").save(open_file=True)
2244
+ """
2245
+ return MakeModelReport(self, title=title, plot_format=plot_format, report_all=report_all)
2246
+
2247
+ @property
2248
+ def markdown_with_estimation(self) -> str:
2249
+ """Original Markdown input with compact estimation tables inserted.
2250
+
2251
+ Each source equation line containing an ``<estimator=...>`` or ``<est=...>`` flag gets
2252
+ a Markdown summary table and coefficient table inserted immediately
2253
+ after it. The underlying model equations are not changed; this is only
2254
+ a documentation/reporting string.
2255
+ """
2256
+ return _markdown_with_estimation_blocks(
2257
+ self.original_statements,
2258
+ self.estimation_records,
2259
+ )
2260
+
2261
+ @property
2262
+ def markdown_model_with_estimation(self) -> str:
2263
+ """Alias for :attr:`markdown_with_estimation`."""
2264
+ return self.markdown_with_estimation
2265
+
2266
+ @property
2267
+ def clean_frml(self) -> str:
2268
+ """Normalized equations with add-factors, exogenization, and fitted-value flags stripped."""
2269
+ parts_list = []
2270
+ for frmlname, expr, (parts, _) in zip(
2271
+ self.normal_output_frmlnames,
2272
+ self.normal_input_expressions,
2273
+ self.normal,
2274
+ ):
2275
+ clean_norm = nz.normal(
2276
+ expr,
2277
+ add_add_factor=False,
2278
+ make_fixable=False,
2279
+ make_fitted=False,
2280
+ the_endo=kw_frml_name(parts.frmlname, 'ENDO'),
2281
+ endo_lhs=False if 'FALSE' == kw_frml_name(parts.frmlname, 'ENDO_LHS', default='1') else True,
2282
+ implicit=kw_frml_name(parts.frmlname, 'IMPLICIT'),
2283
+ )
2284
+ clean_name = _strip_frml_options(frmlname, {'ADD', 'ADD_SUFFIX', 'EXO', 'FIT', 'STOC'})
2285
+ parts_list.append(f'FRML {clean_name} {clean_norm.normalized} $')
2286
+ return '\n'.join(parts_list)
2287
+
2288
+ @property
2289
+ def clean_model(self):
2290
+ """A ModelFlow model with only the core equations (no fitted, add-factor, or exogenization equations)."""
2291
+ from modelclass import model
2292
+ return model(self.clean_frml, funks=self.funks, var_description=self.var_description)
2293
+
2294
+ @property
2295
+ def clean_model_draw(self):
2296
+ """Draw the dependency graph of the clean model."""
2297
+ self.clean_model.drawmodel()
2298
+
2299
+ @property
2300
+ def add_model(self):
2301
+ """A ModelFlow model containing only the add-factor calculations."""
2302
+ from modelclass import model
2303
+ return model(self.normal_calc_add.replace("<CALC_ADD_FACTOR>", "<INIT_ADD>"))
2304
+
2305
+ def init_addfactors(
2306
+ self,
2307
+ df,
2308
+ start: Union[str, int] = "",
2309
+ end: Union[str, int] = "",
2310
+ show: bool = False,
2311
+ check: bool = False,
2312
+ silent: bool = True,
2313
+ multiplier: float = 1.0,
2314
+ ):
2315
+ """Compute add factors and apply them so the model reproduces ``df``.
2316
+
2317
+ Parameters
2318
+ ----------
2319
+ df : pandas.DataFrame
2320
+ Historical data to align with.
2321
+ start, end : str | int, optional
2322
+ Alignment window (defaults to the full range).
2323
+ show : bool, default False
2324
+ If True, print the calculated add factors.
2325
+ check : bool, default False
2326
+ Re-simulate with aligned data and print the residual.
2327
+ silent : bool, default True
2328
+ Silence ModelFlow runtime output.
2329
+ multiplier : float, default 1.0
2330
+ Scale factor applied to the residual check printout.
2331
+
2332
+ Returns
2333
+ -------
2334
+ pandas.DataFrame
2335
+ A copy of ``df`` with add-factor columns filled in.
2336
+ """
2337
+ am = self.add_model
2338
+ aligned = am(df, start, end, silent=silent)
2339
+ if show:
2340
+ print("\n\nAdd factors to align historic values and model results")
2341
+ print(am["*_A"].df)
2342
+ if check:
2343
+ full = self.mmodel
2344
+ _ = full(aligned, start, end, silent=silent)
2345
+ print("\n\nDifference between historic values and model results")
2346
+ if multiplier != 1.0:
2347
+ print(f"Multiplied by {multiplier}")
2348
+ full.basedf = df
2349
+ display(full["#ENDO"].dif.df * multiplier)
2350
+ return aligned
2351
+
2352
+ def get_lists (self) -> str:
2353
+ """
2354
+ Extract all LIST statements (ending with $) from clean_frml_statements.
2355
+ Returns them as a single string, separated by newlines.
2356
+ """
2357
+ if not getattr(self, "clean_frml_statements", ""):
2358
+ return ""
2359
+
2360
+ # split by '$' and strip spaces/newlines
2361
+ statements = [stmt.strip() + " $"
2362
+ for stmt in self.clean_frml_statements.split("$")
2363
+ if stmt.strip()]
2364
+
2365
+ # filter for LIST statements
2366
+ list_statements = [stmt for stmt in statements if stmt.upper().startswith("LIST ")]
2367
+ return "\n".join(list_statements)
2368
+
2369
+
2370
+
2371
+
2372
+ @property
2373
+ def showlists(self):
2374
+ print ( pformat(self.modellist, width=100, compact=False))
2375
+
2376
+ @property
2377
+ def showmodellist(self):
2378
+ print ( pformat(self.modellist, width=100, compact=False))
2379
+
2380
+
2381
+ def pdf(self,**kwargs):
2382
+ pre= r'''
2383
+ {\setlength{\parskip}{1em}
2384
+ \setlength{\parindent}{0pt}
2385
+ '''
2386
+
2387
+ return LatexRepo(fr'{pre} {self.original_statements} }}').pdf(**kwargs)
2388
+
2389
+
2390
+ def __add__ (self, other):
2391
+ if isinstance(other, Makemodel):
2392
+ return Listmodels(makemodels=[self, other])
2393
+ elif isinstance(other, Listmodels):
2394
+ return Listmodels(makemodels= [self] + other.makemodels)
2395
+ else:
2396
+ return NotImplemented
2397
+
2398
+ def __radd__(self, other):
2399
+ # handle reversed order
2400
+ if other == 0:
2401
+ return self
2402
+ if isinstance(other, Makemodel):
2403
+ return Listmodels(makemodels=[other, self])
2404
+ elif isinstance(other, Listmodels):
2405
+ return Listmodels(makemodels=other.makemodels + [self])
2406
+ else:
2407
+ return NotImplemented
2408
+
2409
+
2410
+ def __repr__(self) -> str:
2411
+ def fmt(value: Any) -> str:
2412
+ # Show multiline strings as real blocks (no quotes)
2413
+ if isinstance(value, str) and '\n' in value:
2414
+ return "\n" + textwrap.indent(value.rstrip(), " ")
2415
+ # Pretty containers
2416
+ if isinstance(value, (list, tuple, set, dict)):
2417
+ return pformat(value, width=100, compact=False)
2418
+ # defaultdict / custom objects get pformat fallback via repr-str mix:
2419
+ try:
2420
+ from collections import defaultdict
2421
+ if isinstance(value, defaultdict):
2422
+ return pformat(value, width=100, compact=False)
2423
+ except Exception:
2424
+ pass
2425
+ # Everything else
2426
+ return repr(value)
2427
+
2428
+ items = vars(self)
2429
+ w = max(len(k) for k in items) if items else 0
2430
+ lines = []
2431
+ for k, v in items.items():
2432
+ rendered = fmt(v)
2433
+ if rendered.startswith("\n"): # multiline block
2434
+ lines.append(f"{k:<{w}} :{rendered}")
2435
+ else:
2436
+ lines.append(f"{k:<{w}} : {rendered}")
2437
+ return f"{self.__class__.__name__}(\n " + "\n ".join(lines) + "\n)"
2438
+
2439
+
2440
+
2441
+
2442
+ @dataclass
2443
+ class Listmodels(BaseExplode):
2444
+ """Container for multiple Makemodel instances.
2445
+
2446
+ Enables combination of several Makemodel objects using + operations.
2447
+ """
2448
+
2449
+ makemodels: List['Makemodel'] = field(default_factory=list)
2450
+
2451
+ @property
2452
+ def models(self) -> List['Makemodel']:
2453
+ """Alias for member Makemodel instances.
2454
+
2455
+ The dataclass field remains ``makemodels`` for backwards
2456
+ compatibility with older notebooks and with the old ``Lexplode``
2457
+ constructor keyword.
2458
+ """
2459
+ return self.makemodels
2460
+
2461
+
2462
+ # def __post_init__(self):
2463
+
2464
+ # self.normal_frml = '\n'.join(m.normal_frml.strip() for m in self.makemodels)
2465
+
2466
+ @cached_property
2467
+ def normal_frml(self) -> str:
2468
+ """Compute and cache concatenated normal_frml lazily on first access."""
2469
+ return "\n".join(m.normal_frml.strip() for m in self.makemodels)
2470
+
2471
+ @property
2472
+ def normal_main(self) -> str:
2473
+ """Concatenated core equations from all member Makemodels (no fitted or add-factor equations)."""
2474
+ return "\n".join(
2475
+ m.normal_main.strip()
2476
+ for m in self.makemodels
2477
+ if m.normal_main.strip()
2478
+ )
2479
+
2480
+ @property
2481
+ def normal_calc_add(self) -> str:
2482
+ """Concatenated add-factor calculation equations from all member Makemodels."""
2483
+ return "\n".join(
2484
+ m.normal_calc_add.strip()
2485
+ for m in self.makemodels
2486
+ if m.normal_calc_add.strip()
2487
+ )
2488
+
2489
+ @property
2490
+ def clean_frml(self) -> str:
2491
+ """Concatenated clean equations from all member Makemodels (no add-factors or exogenization)."""
2492
+ return "\n".join(
2493
+ m.clean_frml.strip()
2494
+ for m in self.makemodels
2495
+ if m.clean_frml.strip()
2496
+ )
2497
+
2498
+ @property
2499
+ def clean_model(self):
2500
+ """A ModelFlow model with only the core equations (no fitted, add-factor, or exogenization equations)."""
2501
+ from modelclass import model
2502
+ combined_var_desc = {}
2503
+ for m in self.makemodels:
2504
+ combined_var_desc |= m.var_description
2505
+ combined_funks = [f for m in self.makemodels for f in m.funks]
2506
+ return model(self.clean_frml, funks=combined_funks, var_description=combined_var_desc)
2507
+
2508
+ @property
2509
+ def clean_model_draw(self):
2510
+ """Draw the dependency graph of the clean model."""
2511
+ self.clean_model.drawmodel()
2512
+
2513
+ @property
2514
+ def add_model(self):
2515
+ """A ModelFlow model containing only the add-factor calculations."""
2516
+ from modelclass import model
2517
+ return model(self.normal_calc_add.replace("<CALC_ADD_FACTOR>", "<INIT_ADD>"))
2518
+
2519
+ def init_addfactors(
2520
+ self,
2521
+ df,
2522
+ start: Union[str, int] = "",
2523
+ end: Union[str, int] = "",
2524
+ show: bool = False,
2525
+ check: bool = False,
2526
+ silent: bool = True,
2527
+ multiplier: float = 1.0,
2528
+ ):
2529
+ """Compute add factors and apply them so the combined model reproduces ``df``.
2530
+
2531
+ Parameters
2532
+ ----------
2533
+ df : pandas.DataFrame
2534
+ Historical data to align with.
2535
+ start, end : str | int, optional
2536
+ Alignment window (defaults to the full range).
2537
+ show : bool, default False
2538
+ If True, print the calculated add factors.
2539
+ check : bool, default False
2540
+ Re-simulate with aligned data and print the residual.
2541
+ silent : bool, default True
2542
+ Silence ModelFlow runtime output.
2543
+ multiplier : float, default 1.0
2544
+ Scale factor applied to the residual check printout.
2545
+
2546
+ Returns
2547
+ -------
2548
+ pandas.DataFrame
2549
+ A copy of ``df`` with add-factor columns filled in.
2550
+ """
2551
+ am = self.add_model
2552
+ aligned = am(df, start, end, silent=silent)
2553
+ if show:
2554
+ print("\n\nAdd factors to align historic values and model results")
2555
+ print(am["*_A"].df)
2556
+ if check:
2557
+ full = self.mmodel
2558
+ _ = full(aligned, start, end, silent=silent)
2559
+ print("\n\nDifference between historic values and model results")
2560
+ if multiplier != 1.0:
2561
+ print(f"Multiplied by {multiplier}")
2562
+ full.basedf = df
2563
+ display(full["#ENDO"].dif.df * multiplier)
2564
+ return aligned
2565
+
2566
+ def __add__(self, other):
2567
+ if isinstance(other, Makemodel):
2568
+ return Listmodels(makemodels=self.makemodels + [other])
2569
+ elif isinstance(other, Listmodels):
2570
+ return Listmodels(makemodels=self.makemodels + other.makemodels)
2571
+ else:
2572
+ return NotImplemented
2573
+
2574
+ def __radd__(self, other):
2575
+ if other == 0:
2576
+ return self
2577
+ if isinstance(other, Makemodel):
2578
+ return Listmodels(makemodels=[other] + self.makemodels)
2579
+ elif isinstance(other, Listmodels):
2580
+ return Listmodels(makemodels=other.makemodels + self.makemodels)
2581
+ else:
2582
+ return NotImplemented
2583
+
2584
+
2585
+ def __repr__(self):
2586
+ return f"Listmodels(makemodels={self.makemodels!r})"
2587
+
2588
+ def estimation_report(
2589
+ self,
2590
+ path: str = "html",
2591
+ filename: str = "listmodels_estimation_report.html",
2592
+ plot_format: str = "svg",
2593
+ title: str = "Listmodels Estimation Summary",
2594
+ open_file: bool = False,
2595
+ report_all: bool = False,
2596
+ ) -> None:
2597
+ """Export an HTML report for estimations embedded in all member Makemodels."""
2598
+ return export_makemodel_estimation_reports_to_html(
2599
+ self,
2600
+ path=path,
2601
+ filename=filename,
2602
+ plot_format=plot_format,
2603
+ title=title,
2604
+ open_file=open_file,
2605
+ report_all=report_all,
2606
+ )
2607
+
2608
+ def report(
2609
+ self,
2610
+ title: str = "",
2611
+ plot_format: str = "svg",
2612
+ report_all: bool = False,
2613
+ ) -> "MakeModelReport":
2614
+ """Return a :class:`~modelhtml.MakeModelReport` for this combined model.
2615
+
2616
+ See :meth:`Makemodel.report` for parameter details.
2617
+ """
2618
+ return MakeModelReport(self, title=title, plot_format=plot_format, report_all=report_all)
2619
+
2620
+ @property
2621
+ def markdown_with_estimation(self) -> str:
2622
+ """Concatenate member Makemodel markdown-with-estimation strings."""
2623
+ return "\n\n".join(
2624
+ mex.markdown_with_estimation
2625
+ for mex in self.makemodels
2626
+ )
2627
+
2628
+ @property
2629
+ def markdown_model_with_estimation(self) -> str:
2630
+ """Alias for :attr:`markdown_with_estimation`."""
2631
+ return self.markdown_with_estimation
2632
+
2633
+ # __str__(self):
2634
+
2635
+
2636
+
2637
+
2638
+ def _iter_makemodel_report_sources(obj):
2639
+ """Yield Makemodel instances from a Makemodel or Listmodels report source."""
2640
+ if isinstance(obj, Makemodel):
2641
+ yield obj
2642
+ return
2643
+ if isinstance(obj, Listmodels):
2644
+ for mex in obj.makemodels:
2645
+ yield from _iter_makemodel_report_sources(mex)
2646
+ return
2647
+ raise TypeError(
2648
+ "Expected a Makemodel or Listmodels instance, "
2649
+ f"got {type(obj).__name__}."
2650
+ )
2651
+
2652
+
2653
+ def _is_estimator_backend_instance(eq) -> bool:
2654
+ """True for concrete modelestimator_new estimator backends.
2655
+
2656
+ This is the canonical way to distinguish estimated equations from
2657
+ identity-only Eq objects. All built-in estimators and local user-defined
2658
+ estimators should subclass :class:`modelestimator_new.EstimatorBackend`.
2659
+ """
2660
+ try:
2661
+ from modelestimator_new import EstimatorBackend
2662
+ except Exception:
2663
+ return False
2664
+ return isinstance(eq, EstimatorBackend)
2665
+
2666
+
2667
+ def makemodel_report_equations(makemodel_obj, *, report_all: bool = False) -> list:
2668
+ """Return reportable equation objects from a Makemodel/Listmodels.
2669
+
2670
+ Estimated equations are identified by ``isinstance(eq, EstimatorBackend)``.
2671
+ When ``report_all=True``, unestimated equations are wrapped as lightweight
2672
+ :class:`modelestimator_new.Eq` instances so the shared HTML exporter can
2673
+ render minimal identity panels.
2674
+ """
2675
+ from modelestimator_new import Eq
2676
+
2677
+ equations = []
2678
+ for mex in _iter_makemodel_report_sources(makemodel_obj):
2679
+ records = list(getattr(mex, "estimation_records", []))
2680
+ records_by_index = {
2681
+ rec.get("equation_index"): rec
2682
+ for rec in records
2683
+ if rec.get("equation_index") is not None
2684
+ }
2685
+
2686
+ # New objects have equation_index in each record, so we can preserve
2687
+ # the expanded-equation order and optionally interleave identities.
2688
+ if records_by_index:
2689
+ normal_inputs = list(getattr(mex, "normal_input_expressions", []))
2690
+ for i, parts in enumerate(getattr(mex, "expanded_frml_split", [])):
2691
+ rec = records_by_index.get(i)
2692
+ if rec is not None:
2693
+ eq = rec.get("estimator_object")
2694
+ if _is_estimator_backend_instance(eq):
2695
+ equations.append(eq)
2696
+ continue
2697
+ if not report_all:
2698
+ continue
2699
+ expression = (
2700
+ normal_inputs[i]
2701
+ if i < len(normal_inputs)
2702
+ else parts.expression
2703
+ )
2704
+ equations.append(Eq(
2705
+ org_eq=expression,
2706
+ frml_name=parts.frmlname,
2707
+ ))
2708
+ continue
2709
+
2710
+ # Backwards compatibility for Makemodel objects created before
2711
+ # equation_index was added: report EstimatorBackend instances, but
2712
+ # exact interleaving with identities is not available.
2713
+ equations.extend(
2714
+ eq
2715
+ for eq in (rec.get("estimator_object") for rec in records)
2716
+ if _is_estimator_backend_instance(eq)
2717
+ )
2718
+
2719
+ return equations
2720
+
2721
+
2722
+ def export_makemodel_estimation_reports_to_html(
2723
+ makemodel_obj,
2724
+ path: str = "html",
2725
+ filename: str = "makemodel_estimation_report.html",
2726
+ plot_format: str = "svg",
2727
+ title: str = "Makemodel Estimation Summary",
2728
+ open_file: bool = False,
2729
+ report_all: bool = False,
2730
+ ) -> None:
2731
+ """Export an HTML report for the estimated equations in a Makemodel.
2732
+
2733
+ Parameters mirror :func:`modelestimator_new.export_estimation_reports_to_html`.
2734
+ ``makemodel_obj`` may be either a single :class:`Makemodel` or a
2735
+ :class:`Listmodels` combining several of them.
2736
+ """
2737
+ from modelestimator_new import export_estimation_reports_to_html
2738
+
2739
+ equations = makemodel_report_equations(
2740
+ makemodel_obj,
2741
+ report_all=report_all,
2742
+ )
2743
+
2744
+ if not equations:
2745
+ print(
2746
+ "[Makemodel.estimation_report] No estimated equations found. "
2747
+ "Add <estimator=...> or <est=...> to one or more equations before requesting "
2748
+ "an estimation report."
2749
+ )
2750
+ return
2751
+
2752
+ if not report_all:
2753
+ identity_count = 0
2754
+ for mex in _iter_makemodel_report_sources(makemodel_obj):
2755
+ n_expanded = len(getattr(mex, "expanded_frml_split", []))
2756
+ n_estimated = sum(
2757
+ 1
2758
+ for rec in getattr(mex, "estimation_records", [])
2759
+ if _is_estimator_backend_instance(rec.get("estimator_object"))
2760
+ )
2761
+ identity_count += max(0, n_expanded - n_estimated)
2762
+ if identity_count:
2763
+ print(
2764
+ f"[Makemodel.estimation_report] Including {len(equations)} "
2765
+ f"estimated equation(s); skipping {identity_count} identity "
2766
+ "equation(s). Pass report_all=True to include identities too."
2767
+ )
2768
+
2769
+ return export_estimation_reports_to_html(
2770
+ equations=equations,
2771
+ path=path,
2772
+ filename=filename,
2773
+ plot_format=plot_format,
2774
+ title=title,
2775
+ open_file=open_file,
2776
+ report_all=report_all,
2777
+ )
2778
+
2779
+
2780
+
2781
+ # MakeModelReport lives in modelhtml.py; re-exported here for convenience.
2782
+ from modelhtml import MakeModelReport
2783
+
2784
+
2785
+ # -----------------------------------------------------------------------------
2786
+ # Backwards compatibility aliases
2787
+ # -----------------------------------------------------------------------------
2788
+ # ``Makemodel`` is the new public name. These aliases keep older notebooks
2789
+ # importing/constructing ``Mexplode`` or calling the old helper names working
2790
+ # while code is migrated.
2791
+ Mexplode = Makemodel
2792
+ Lexplode = Listmodels
2793
+ mexplode_report_equations = makemodel_report_equations
2794
+ export_mexplode_estimation_reports_to_html = export_makemodel_estimation_reports_to_html
2795
+
2796
+ if __name__ == '__main__' :
2797
+ #%%
2798
+ pass
2799
+ if 0:
2800
+ res3 = Makemodel('''
2801
+ <endo=f,stoc> a = gamma+ f+O
2802
+ <endo=f> a = gamma+ f+O
2803
+ <endo=x,stoc,endo_lhs> a+x = gamma+ f+O
2804
+ <endo=x,stoc> a+x = gamma+ f+O
2805
+ <fit,exo,stoc> c = b*42
2806
+
2807
+ ''',replacements=None)
2808
+
2809
+ res3.shownormal_expressions
2810
+ tlists = '''
2811
+ LIST BANKS = BANKS : IB SOREN MARIE /
2812
+ COUNTRY : DENMARK SWEDEN DENMARK /
2813
+ SELECTED : 1 0 1
2814
+ $
2815
+ LIST SECTORS = SECTORS : NFC SME HH $
2816
+
2817
+ '''
2818
+
2819
+ xx = '''
2820
+ doable <HEST,sum=abe> [banks=country=sweden, sektors=sektors] LOSS__{BANKS}__{SECTORs} = HOLDING__{BANKS}__{SECTORs} * PD__{BANKS}__{SECTORs}$
2821
+ doable <HEST,sum=goat> [banks=country=denmark,sektors=sektors] LOSS2__{BANKS}__{SECTORs} = HOLDING__{BANKS}__{SECTORs} * PD__{BANKS}__{SECTORs}$
2822
+ do sectors $
2823
+ frml <> x_{sectors} = 42 $
2824
+ enddo $
2825
+
2826
+ '''.upper()
2827
+ # xx = 'doabel <HEST,sum=abe> LOSS__{BANKS}__{SECTORs} =HOLDING__{BANKS}__{SECTORs} * PD__{BANKS}__{SECTORs} $'.upper()
2828
+
2829
+ # res = Makemodel(tlists+xx)
2830
+ # print(res)
2831
+ # breakpoint()
2832
+ res2 = Makemodel('''
2833
+ LIST BANKS = BANKS : IB SOREN MARIE /
2834
+ COUNTRY : DENMARK SWEDEN DENMARK /
2835
+ SELECTED : 1 1 0
2836
+ LIST SECTORS = SECTORS : NFC SME HH
2837
+ LIST test = t : 100*104
2838
+ a = b
2839
+ c = 33
2840
+ £ test
2841
+ doable <HEST,sum=goat> [banks country=denmark] LOSS2__{BANKS}__{SECTORs} = HOLDING__{BANKS}__{SECTORs} * PD__{BANKS}__{SECTORs}
2842
+ do banks
2843
+ £ sector {country}
2844
+ frml <> x_{banks} = 42
2845
+ enddo
2846
+
2847
+ ''')
2848
+
2849
+ print(res2+res3)
2850
+
2851
+ if 0:
2852
+ test1= Makemodel('a=1')
2853
+ test2= Makemodel('b=2')
2854
+ test3= Makemodel('c=3')
2855
+ print(test1+(test2+test3))
2856
+ print(test1)
2857
+
2858
+ text = """>list ages = ages : age_0 * age_11
2859
+ >list sexes = sexes : female male /
2860
+ > fertile : 1 0
2861
+ other stuf
2862
+ and yes
2863
+ > dekdkd"""
2864
+
2865
+
2866
+
2867
+ print(text2:=mfmod_list_to_markdown(text))
2868
+
2869
+ #%% latex
2870
+ leq = r'CRF^{l,es,d}=\frac{DiscountRate^{l,es,d}}{1 - \left(1 + DiscountRate^{l,es,d}\right)^{-LifeSpan^{l,es,d}}}'
2871
+ meq = latex_to_doable(leq, '<ii>')
2872
+ print(meq)