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.
modelconstruct.py ADDED
@@ -0,0 +1,1496 @@
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
+
15
+
16
+ from pprint import pformat
17
+ import textwrap
18
+
19
+ from modelmanipulation import tofrml,dounloop, sumunroll
20
+ from modelpattern import find_statements,split_frml,find_frml,list_extract,udtryk_parse,kw_frml_name,commentchar,split_frml_reqopts,rebuild_list
21
+ from modelpattern import namepat, check_syntax_model
22
+ from modelhelp import debug_var
23
+ from model_latex_class import a_latex_model,a_latex_equation,defrack, depower,debrace,defunk
24
+ from modelreport import LatexRepo
25
+
26
+ class ModelSpecificationError(Exception):
27
+ pass
28
+
29
+
30
+ def clean_expressions(original_statements: str) -> str:
31
+ """
32
+ Pipeline
33
+ --------
34
+ 1. Uppercase the DSL.
35
+ 2. Collapse LIST blocks to one line, preserving whether they ended with '$'.
36
+ 3. Run tofrml().
37
+ 4. Rebuild LIST blocks through:
38
+ list_extract(..., add_auto_sublists=False)
39
+ rebuild_list(...)
40
+ """
41
+ import re
42
+
43
+ stmt_start = re.compile(r'^\s*(LIST|TLIST|FRML|DO|ENDDO|DOABLE)\b')
44
+
45
+ def is_list_start(line: str) -> bool:
46
+ # Both LIST and TLIST start a list block. TLIST is the transposed
47
+ # form: the second "row" gives sublist names and values run down
48
+ # the columns. TLIST is converted to standard LIST during
49
+ # normalize_lists() so downstream code only ever sees LIST.
50
+ return bool(re.match(r'^\s*(LIST|TLIST)\b', line))
51
+
52
+ def is_tlist_start(line: str) -> bool:
53
+ return bool(re.match(r'^\s*TLIST\b', line))
54
+
55
+ def collect_list_block(lines, start_index):
56
+ """
57
+ Collect one LIST block starting at start_index.
58
+
59
+ A block continues while the current line ends with '/'.
60
+ A block ends when the current line ends with '$', or when
61
+ the current line does not end with '/'.
62
+ """
63
+ block_lines = [lines[start_index].rstrip()]
64
+ j = start_index + 1
65
+
66
+ while j < len(lines):
67
+ prev = block_lines[-1].rstrip()
68
+
69
+ if prev.endswith('$'):
70
+ break
71
+
72
+ if prev.endswith('/'):
73
+ block_lines.append(lines[j].rstrip())
74
+ j += 1
75
+ continue
76
+
77
+ break
78
+
79
+ return '\n'.join(block_lines), j
80
+
81
+ def _convert_tlist_to_list(flat_no_dollar: str) -> str:
82
+ """
83
+ Convert a flattened TLIST single-line form to standard LIST form.
84
+
85
+ Input (flat, no trailing '$'):
86
+ TLIST <name> = h1 h2 h3 / v11 v12 v13 / v21 v22 v23 / v31 v32 v33
87
+
88
+ Output:
89
+ LIST <name> = h1 : v11 v21 v31 / h2 : v12 v22 v32 / h3 : v13 v23 v33
90
+
91
+ The first '/'-separated chunk after '=' is the header row giving
92
+ sublist names. Each subsequent chunk is one tuple of values, one
93
+ value per sublist. Data therefore runs *down* the columns, hence
94
+ 'transposed list'.
95
+ """
96
+ # strip the leading TLIST keyword
97
+ m = re.match(r'^\s*TLIST\b\s*(.*)$', flat_no_dollar, flags=re.IGNORECASE)
98
+ if not m:
99
+ raise ModelSpecificationError(
100
+ f"Internal: _convert_tlist_to_list called on non-TLIST: {flat_no_dollar!r}"
101
+ )
102
+ body = m.group(1).strip()
103
+
104
+ if '=' not in body:
105
+ raise ModelSpecificationError(
106
+ f"TLIST is missing '=' between name and contents: {flat_no_dollar!r}"
107
+ )
108
+ list_name, rhs = body.split('=', 1)
109
+ list_name = list_name.strip()
110
+ if not list_name:
111
+ raise ModelSpecificationError(
112
+ f"TLIST has empty name: {flat_no_dollar!r}"
113
+ )
114
+
115
+ # split rows on '/'
116
+ raw_rows = [r.strip() for r in rhs.split('/')]
117
+ raw_rows = [r for r in raw_rows if r != '']
118
+ if len(raw_rows) < 2:
119
+ raise ModelSpecificationError(
120
+ f"TLIST {list_name} needs a header row and at least one value row "
121
+ f"(rows separated by '/'); got: {flat_no_dollar!r}"
122
+ )
123
+
124
+ def tokens(row: str):
125
+ # split on whitespace or commas, drop empties
126
+ return [t for t in re.split(r'[\s,]+', row.strip()) if t != '']
127
+
128
+ header = tokens(raw_rows[0])
129
+ value_rows = [tokens(r) for r in raw_rows[1:]]
130
+
131
+ if not header:
132
+ raise ModelSpecificationError(
133
+ f"TLIST {list_name} has empty header row"
134
+ )
135
+
136
+ ncols = len(header)
137
+ for k, vr in enumerate(value_rows, 1):
138
+ if len(vr) != ncols:
139
+ raise ModelSpecificationError(
140
+ f"TLIST {list_name}: value row {k} has {len(vr)} entries "
141
+ f"but header has {ncols} ({header})"
142
+ )
143
+
144
+ # transpose: column j across all value rows becomes the j-th sublist
145
+ sublists = []
146
+ for j, sublist_name in enumerate(header):
147
+ col = [vr[j] for vr in value_rows]
148
+ sublists.append(f"{sublist_name} : {' '.join(col)}")
149
+
150
+ return f"LIST {list_name} = " + ' / '.join(sublists)
151
+
152
+ def normalize_list_block(block: str) -> str:
153
+ """
154
+ Flatten one LIST or TLIST block to a single line.
155
+ Preserve a trailing '$' if present in the original block.
156
+
157
+ TLIST blocks are transposed into the canonical LIST form here, so
158
+ every later stage (tofrml, list_extract, rebuild_list, ...) only
159
+ ever sees LIST.
160
+ """
161
+ saw_dollar = bool(re.search(r'\$\s*$', block))
162
+ is_tlist = bool(re.match(r'^\s*TLIST\b', block))
163
+
164
+ flat = ' '.join(line.strip() for line in block.splitlines())
165
+ flat = re.sub(r'\s+', ' ', flat).strip()
166
+
167
+ # remove one trailing '$' and one trailing '/'
168
+ flat = re.sub(r'\s*\$\s*$', '', flat)
169
+ flat = re.sub(r'\s*/\s*$', '', flat)
170
+
171
+ if is_tlist:
172
+ flat = _convert_tlist_to_list(flat)
173
+
174
+ if saw_dollar:
175
+ flat = flat + ' $'
176
+
177
+ return flat
178
+
179
+ def normalize_lists(text: str) -> str:
180
+ lines = text.splitlines()
181
+ out = []
182
+ i = 0
183
+
184
+ while i < len(lines):
185
+ if not is_list_start(lines[i]):
186
+ out.append(lines[i])
187
+ i += 1
188
+ continue
189
+
190
+ block, next_i = collect_list_block(lines, i)
191
+ out.append(normalize_list_block(block))
192
+ i = next_i
193
+
194
+ return '\n'.join(out)
195
+
196
+ def restore_list_block(block: str) -> str:
197
+ """
198
+ Rebuild one LIST block through list_extract() + rebuild_list().
199
+ """
200
+ saw_dollar = bool(re.search(r'\$\s*$', block))
201
+
202
+ parse_block = block if saw_dollar else block.rstrip() + ' $'
203
+ list_dict = list_extract(parse_block, add_auto_sublists=False)
204
+ rebuilt = rebuild_list(list_dict)
205
+
206
+ if not saw_dollar:
207
+ rebuilt = re.sub(r'\s*\$\s*$', '', rebuilt)
208
+
209
+ return rebuilt
210
+
211
+ def restore_lists(text: str) -> str:
212
+ lines = text.splitlines()
213
+ out = []
214
+ i = 0
215
+
216
+ while i < len(lines):
217
+ if not is_list_start(lines[i]):
218
+ out.append(lines[i])
219
+ i += 1
220
+ continue
221
+
222
+ # after normalize_lists(), LIST blocks are normally single-line,
223
+ # but we keep block collection here for robustness
224
+ block_lines = [lines[i].rstrip()]
225
+ j = i + 1
226
+
227
+ while j < len(lines):
228
+ prev = block_lines[-1].rstrip()
229
+
230
+ if prev.endswith('$'):
231
+ break
232
+
233
+ if prev.endswith('/'):
234
+ block_lines.append(lines[j].rstrip())
235
+ j += 1
236
+ continue
237
+
238
+ if stmt_start.match(lines[j]):
239
+ break
240
+
241
+ break
242
+
243
+ block = '\n'.join(block_lines)
244
+ out.append(restore_list_block(block))
245
+ i = j
246
+
247
+ return '\n'.join(out)
248
+
249
+ up = original_statements.upper()
250
+ flattened = normalize_lists(up)
251
+ frml_added = tofrml(flattened)
252
+ return restore_lists(frml_added)
253
+
254
+ def doable_unroll(in_equations,funks=[]):
255
+ ''' expands all sum(list,'expression') in a model
256
+ returns a new model'''
257
+ import model_latex_class as ml
258
+ nymodel = []
259
+ equations = in_equations[:].upper() # we want do change the e
260
+ # debug_var(equations)
261
+
262
+ for comment, command, value in find_statements(equations):
263
+ # print('>>',comment,'<',command,'>',value)
264
+ # debug_var(comment,command,value)
265
+ if comment:
266
+ nymodel.append(comment)
267
+ else:
268
+ if command == 'DOABLE':
269
+ unrolled = ml.doable(value)
270
+ nymodel.append(unrolled + ' ')
271
+ # debug_var(unrolled)
272
+
273
+ else:
274
+ nymodel.append(command + ' ' + value)
275
+ equations = '\n'.join(nymodel)
276
+ # debug_var(equations)
277
+ return equations
278
+
279
+ # Simple extractor
280
+ def extract_model_from_markdown(md_text: str) -> str:
281
+ """Extracts model lines (starting with '>') from Markdown."""
282
+ return "\n".join(
283
+ re.findall(r'^[>]\s?(.*)', md_text, flags=re.MULTILINE)
284
+ ).strip()
285
+
286
+
287
+
288
+ TAG_RE = re.compile(r'^%\s*@<([^>]+)>')
289
+
290
+
291
+ def extract_latex_equation_block(lines, start):
292
+ block = []
293
+ i = start + 1
294
+
295
+ while i < len(lines) and r"\end{equation}" not in lines[i]:
296
+ block.append(lines[i])
297
+ i += 1
298
+
299
+ return block, i + 1
300
+
301
+
302
+
303
+ def extract_model_from_markdown(md_text: str, list_position: str = "front") -> str:
304
+ """
305
+ Extract ModelFlow model equations from markdown.
306
+
307
+ Parameters
308
+ ----------
309
+ md_text : str
310
+ Markdown text containing model equations and LaTeX.
311
+ list_position : {"front", "end"}
312
+ Where extracted LIST definitions should be placed.
313
+ """
314
+
315
+ if list_position not in {"front", "end"}:
316
+ raise ValueError("list_position must be 'front' or 'end'")
317
+
318
+ model_lines = []
319
+ latex_buffer = [] # collect ALL LaTeX for LIST extraction
320
+
321
+ lines = md_text.splitlines()
322
+ current = ""
323
+ i = 0
324
+ n = len(lines)
325
+
326
+ in_dollars = False
327
+
328
+ while i < n:
329
+ line = lines[i]
330
+ stripped = line.lstrip()
331
+
332
+ # ----------------------------------------------------------
333
+ # Track $$ display math
334
+ # ----------------------------------------------------------
335
+ if stripped.startswith("$$"):
336
+ in_dollars = not in_dollars
337
+ latex_buffer.append(line)
338
+ i += 1
339
+ continue
340
+
341
+ if in_dollars:
342
+ latex_buffer.append(line)
343
+ i += 1
344
+ continue
345
+
346
+ # ----------------------------------------------------------
347
+ # LaTeX equation environment (standalone)
348
+ # ----------------------------------------------------------
349
+ if stripped.startswith(r"\begin{equation}"):
350
+ block, i = extract_latex_equation_block(lines, i)
351
+
352
+ tag = None
353
+ label = None
354
+ body_lines = []
355
+
356
+ for l in block:
357
+ s = l.strip()
358
+ if not s:
359
+ continue
360
+
361
+ latex_buffer.append(s)
362
+
363
+ # % @<...>
364
+ if s.startswith('%'):
365
+ m = TAG_RE.match(s)
366
+ if m:
367
+ tag = m.group(1)
368
+ continue
369
+
370
+ # \label{eq:...}
371
+ if s.startswith(r"\label{eq:"):
372
+ label = s[len(r"\label{eq:"):-1]
373
+ continue
374
+
375
+ body_lines.append(s)
376
+
377
+ if label and body_lines:
378
+ final_tag = f'<{tag}>' if tag else '<>'
379
+ body = " ".join(body_lines)
380
+ model_lines.append(
381
+ latex_to_doable(body, tag=final_tag)
382
+ )
383
+ continue
384
+
385
+ # ----------------------------------------------------------
386
+ # Inline LaTeX (for LIST detection)
387
+ # ----------------------------------------------------------
388
+ if "$" in line:
389
+ latex_buffer.append(line)
390
+
391
+ # ----------------------------------------------------------
392
+ # Markdown continuation >>
393
+ # ----------------------------------------------------------
394
+ if stripped.startswith(">>"):
395
+ current += " " + stripped[2:].lstrip()
396
+ i += 1
397
+ continue
398
+
399
+ # ----------------------------------------------------------
400
+ # Markdown model line >
401
+ # ----------------------------------------------------------
402
+ if stripped.startswith(">"):
403
+ if current.strip():
404
+ model_lines.append(current.strip())
405
+ current = stripped[1:].lstrip()
406
+ i += 1
407
+ continue
408
+
409
+ # ----------------------------------------------------------
410
+ # Flush on non-model line
411
+ # ----------------------------------------------------------
412
+ if current.strip():
413
+ model_lines.append(current.strip())
414
+ current = ""
415
+
416
+ i += 1
417
+
418
+ # ----------------------------------------------------------
419
+ # Final flush
420
+ # ----------------------------------------------------------
421
+ if current.strip():
422
+ model_lines.append(current.strip())
423
+
424
+ # ----------------------------------------------------------
425
+ # GLOBAL LIST extraction (ONCE)
426
+ # ----------------------------------------------------------
427
+ lists = findlists_in_latex(md_text)
428
+ # debug_var(lists,model_lines)
429
+ if lists:
430
+ if list_position == "front":
431
+ model_lines = [lists] + model_lines
432
+ else: # "end"
433
+ model_lines = model_lines + [lists]
434
+
435
+ return "\n".join(model_lines)
436
+
437
+
438
+
439
+ # def findlists_in_latex(input):
440
+ # '''extracte list with sublist from latex'''
441
+ # relevant = re.findall(r'\$LIST\s*\\;\s*[^$]*\$',input.upper())
442
+ # # print(f'{relevant=}')
443
+ # temp1 = [l.replace('$','').replace('\\','')
444
+ # .replace(',',' ').replace(';',' ')
445
+ # .replace('{','').replace('}','').replace('\n','/ \n')
446
+ # for l in relevant]
447
+ # # print(f'\n{temp1=}\n')
448
+ # temp2 = ['LIST ' + l.split('=')[0][4:].strip() +' = '
449
+ # + l.split('=')[0][4:]
450
+ # +' : '+ l.split('=')[1] for l in temp1]
451
+
452
+ # return ('\n'.join(temp2)+'\n')
453
+
454
+
455
+ import re
456
+
457
+ import re
458
+
459
+ def clean_mathjax(s):
460
+ s = (
461
+ s.replace('$$\n','')
462
+ .replace('$','')
463
+ .replace('\\allowbreak',' ')
464
+ .replace('\\\\',' ')
465
+ .replace('\\begin{aligned}','')
466
+ .replace('\\end{aligned}','')
467
+ .replace(r'\{','')
468
+ .replace(r'\}','')
469
+ .replace(',',' ')
470
+ .replace(r'\;',' ')
471
+ .replace('\n','/ \n')
472
+ .replace(r'\_','_')
473
+ )
474
+ # remove line-start + blanks
475
+ return re.sub(r'(?m)^\s+', '', s)
476
+
477
+
478
+
479
+ def findlists_in_latex(input):
480
+ '''extract list with possible MathJax line breaks'''
481
+
482
+ relevant = re.findall(
483
+ r'\${1,2}\s*LIST\s*\\;\s*.*?\${1,2}',
484
+ input,
485
+ flags=re.IGNORECASE | re.DOTALL
486
+ )
487
+ temp0 = [l.upper() for l in relevant]
488
+ temp1 = [clean_mathjax(l.upper()) for l in relevant]
489
+
490
+ temp2 = [
491
+ 'LIST ' + l.split('=')[0][4:].strip()
492
+ + ' = ' + l.split('=')[0][4:]
493
+ + ' : ' + l.split('=')[1]
494
+ for l in temp1
495
+ ]
496
+ # debug_var(temp0,temp1,temp2)
497
+ result = '\n'.join(temp2) + '\n'
498
+ return result
499
+
500
+
501
+
502
+ def latex_to_doable(temp,tag='<>'):
503
+ """
504
+ Given a LaTeX equation string in `temp`, this function processes and converts it to a more standardized format.
505
+
506
+ Args:
507
+ temp (str): A LaTeX equation string to be processed and standardized.
508
+
509
+ Returns:
510
+ str: A processed and standardized version of the input `temp` string.
511
+
512
+ Raises:
513
+ None.
514
+ """
515
+
516
+ if type(temp) == type(None):
517
+ return None
518
+ trans={r'\left':'',
519
+ r'\right':'',
520
+ # r'\min':'min',
521
+ # r'\max':'max',
522
+ r'\rho':'rho',
523
+ r'\alpha':'alpha',
524
+ r'\beta':'beta',
525
+ r'\tau':'tau',
526
+ r'\sigma':'sigma',
527
+ r'\exp':'exp',
528
+ r'&':'',
529
+ r'\\':'',
530
+ r'\nonumber' : '',
531
+ r'\_' : '_',
532
+ r'_{t}' : '',
533
+ r"_t(?![a-zA-Z0-9])" :'',
534
+ 'logit^{-1}' : 'logit_inverse',
535
+ r'\{' : '{',
536
+ r'\}' : '}',
537
+ r'\begin{split}' : '',
538
+ '\n' :'',
539
+ r'\forall' :'',
540
+ r'\;' :'',
541
+ r'\:' :'',
542
+ r'\,' :'',
543
+ r'\big' :'',
544
+ r'\begin{aligned}' :'',
545
+ r'\end{aligned}' :'',
546
+ r' ' :' ',
547
+
548
+
549
+ }
550
+ ftrans = {
551
+ r'\sqrt':'sqrt',
552
+ r'\Delta':'diff',
553
+ r'\Phi':'NORM.CDF',
554
+ r'\Phi^{-1}':'NORM.PDF'
555
+ }
556
+ regtrans = {
557
+ r'\\Delta ([A-Za-z_][\w{},\^]*)':r'diff(\1)', # \Delta xy => diff(xy)
558
+ r'_{t-([1-9]+)}' : r'(-\1)', # _{t-x} => (-x)
559
+ r'_{t\+([1-9]+)}' : r'(+\1)', # _{t+x} => (+x)
560
+
561
+ # r'\^([\w])' : r'_\1', # ^x => _x
562
+ # r'\^\{([\w]+)\}(\w)' : r'_\1_\2', # ^{xx}y => _xx_y
563
+ r'\^{([\w+-]+)}' : r'__{\1}', # ^{xx} => _xx
564
+ r'\^{([\w+-]+),([\w+-]+)\}' : r'__{\1}__{\2}', # ^{xx,yy} => _xx_yy
565
+ r'\^{([\w+-]+),([\w+-]+),([\w+-]+)\}' : r'__{\1}__{\2}__{\3}', # ^{xx,yy} => _xx_yy
566
+ r'\^{([\w+-]+),([\w+-]+),([\w+-]+),([\w+-]+)\}' : r'__{\1}__{\2}__{\3}__{\4}', # ^{xx,yy} => _xx_yy
567
+ r'\s*\\times\s*':'*' ,
568
+ r'\s*\\cdot\s*':'*' ,
569
+ r'\\text{\[([\w+-,.]+)\]}' : r'[\1]',
570
+ r'\\sum_{('+namepat+r')}\(' : r'sum(\1,',
571
+ r"\\sum_{([a-zA-Z][a-zA-Z0-9_]*)=([a-zA-Z][a-zA-Z0-9_]*)}\(": r'sum(\1 \2=1,',
572
+
573
+ r'\\max_{('+namepat+r')}\(' : r'lmax(\1,',
574
+ r"\\max_{([a-zA-Z][a-zA-Z0-9_]*)=([a-zA-Z][a-zA-Z0-9_]*)}\(": r'lmax(\1 \2=1,',
575
+ r'\\min_{('+namepat+r')}\(' : r'lmin(\1,',
576
+ r"\\min_{([a-zA-Z][a-zA-Z0-9_]*)=([a-zA-Z][a-zA-Z0-9_]*)}\(": r'lmin(\1 \2=1,',
577
+
578
+ }
579
+ # breakpoint()
580
+ try:
581
+ for before,to in ftrans.items():
582
+ temp = defunk(before,to,temp)
583
+ except:
584
+ print(f'{before=} {to=} {temp=}')
585
+ # debug_var(temp)
586
+
587
+ for before,to in trans.items():
588
+ temp = temp.replace(before,to)
589
+ # debug_var(temp)
590
+
591
+ for before,to in regtrans.items():
592
+ temp = re.sub(before,to,temp)
593
+ # debug_var(temp)
594
+ temp = debrace(temp)
595
+ temp = defrack(temp)
596
+ temp = depower(temp)
597
+ temp = ' '.join(temp.split())
598
+ if '__' in temp:
599
+ res = f'doable {tag} {temp}'
600
+ else:
601
+ res = f'{tag} {temp}'
602
+
603
+ if '\\' in res:
604
+ raise ModelSpecificationError(
605
+ f"Some LaTeX has survived in the model (excerpt): {res[:200]}"
606
+ )
607
+ return res
608
+
609
+
610
+
611
+ def apply_replacements(formulas: str, replacements: Union[tuple[str, str], list[tuple[str, str]]]) -> str:
612
+ """
613
+ Apply one or more string replacements to a formula string.
614
+
615
+ Parameters
616
+ ----------
617
+ formulas : str
618
+ The input string containing one or more formulas.
619
+ replacements : tuple[str, str] or list[tuple[str, str]]
620
+ Either a single (pattern, replacement) tuple or
621
+ a list of such tuples.
622
+
623
+ Example:
624
+ ('__dim', '__{banks}__{country}__{ports}')
625
+ or:
626
+ [
627
+ ('__dim', '__{banks}__{country}__{ports}'),
628
+ ('GDP', 'GDP_REAL')
629
+ ]
630
+
631
+ Returns
632
+ -------
633
+ str
634
+ The modified formula string after all replacements have been applied.
635
+ If no replacements are given or replacements is empty, the original
636
+ string is returned unchanged.
637
+ """
638
+ if not replacements:
639
+ return formulas
640
+
641
+ # Ensure replacements is a list of tuples
642
+ if isinstance(replacements, tuple) and len(replacements) == 2 and isinstance(replacements[0], str):
643
+ replacements = [replacements]
644
+
645
+ updated = formulas
646
+ for old, new in replacements:
647
+ updated = updated.replace(old, new)
648
+ return updated
649
+
650
+ def mfmod_list_to_markdown(text: str) -> str:
651
+ """
652
+ Convert MFMod-style list definitions (EViews/ModelFlow format) into
653
+ aligned Markdown tables, while keeping all other lines unchanged.
654
+
655
+ Example:
656
+ Input:
657
+ >list ages = ages : age_0 * age_11
658
+ >list sexes = sexes : female male /
659
+ > fertile : 1 0
660
+ other stuff
661
+
662
+ Output:
663
+ (Markdown tables for lists with blank line before each)
664
+ other stuff
665
+
666
+ Behavior:
667
+ - Detects lines starting with '>list' as list definitions.
668
+ - Parses sublists separated by '/' and items separated by whitespace.
669
+ - Renders Markdown tables with aligned columns and clear '|'.
670
+ - Leaves all non-list lines unchanged (including indentation).
671
+ - Adds one blank line before each rendered list.
672
+ """
673
+
674
+ lines = text.splitlines()
675
+ output = []
676
+
677
+ # Temporary buffers for list parsing
678
+ current_list = None
679
+ current_entries = []
680
+ inside_list = False
681
+ raw_block = [] # keep original lines for fallback
682
+
683
+ def flush():
684
+ """
685
+ Finalize the current list block:
686
+ - If successfully parsed → render as Markdown table.
687
+ - If invalid → output raw block unchanged.
688
+ - Always reset list state.
689
+ """
690
+ nonlocal current_list, current_entries, inside_list, raw_block
691
+ if current_list and current_entries:
692
+ # Add a blank line before each table unless we’re at the top
693
+ if output and output[-1].strip():
694
+ output.append("")
695
+ output.append(render_list_table(current_list, current_entries))
696
+ output.append("") # 👈 ensures a blank line after the table
697
+
698
+ elif inside_list and raw_block:
699
+ # List start detected but not parsed → keep original text
700
+ if output and output[-1].strip():
701
+ output.append("")
702
+ output.extend(raw_block)
703
+
704
+ # Reset for next possible list
705
+ current_list, current_entries, inside_list, raw_block = None, [], False, []
706
+
707
+ # --- Main parsing loop ---
708
+ for line in lines:
709
+ stripped = line.strip()
710
+
711
+ # Case 1: Start of new list definition
712
+ if stripped.startswith(">list"):
713
+ flush() # finish any previous list
714
+ inside_list = True
715
+ raw_block = [line]
716
+
717
+ # Try to extract list name and right-hand content
718
+ match = re.match(r'>list\s+(\w+)\s*=\s*(.*)', stripped)
719
+ if match:
720
+ current_list, rest = match.groups()
721
+ current_entries.extend(parse_sublists(rest))
722
+ else:
723
+ # only name, content may follow later
724
+ current_list = stripped.replace('>list', '').strip()
725
+
726
+ # Case 2: Continuation lines (e.g., '> fertile : 1 0')
727
+ elif inside_list and stripped.startswith(">") and not stripped.startswith(">list"):
728
+ raw_block.append(line)
729
+ content = stripped[1:].strip()
730
+ current_entries.extend(parse_sublists(content))
731
+
732
+ # Case 3: A non-'>' line ends the list block
733
+ elif inside_list and not stripped.startswith(">"):
734
+ flush()
735
+ output.append(line)
736
+
737
+ # Case 4: Outside of any list
738
+ else:
739
+ if inside_list:
740
+ flush()
741
+ output.append(line)
742
+
743
+ # Handle any unfinished list at the end
744
+ flush()
745
+
746
+ return "\n".join(output)
747
+
748
+
749
+ def mfmod_list_to_codeblock(text: str) -> str:
750
+ """
751
+ Wrap MFMod-style list definitions (lines starting with '>list' and following '>' lines)
752
+ inside triple backticks with 'text' language tag so they render as fixed-width blocks
753
+ in Jupyter Book. All non-list lines are left unchanged.
754
+
755
+ Example:
756
+ Input:
757
+ >list ages = ages : age_0 * age_11
758
+ >list sexes = sexes : female male /
759
+ > fertile : 1 0
760
+ other stuff
761
+
762
+ Output:
763
+ ```text
764
+ >list ages = ages : age_0 * age_11
765
+ ```
766
+ ```text
767
+ >list sexes = sexes : female male /
768
+ > fertile : 1 0
769
+ ```
770
+ other stuff
771
+ """
772
+ lines = text.splitlines()
773
+ output = []
774
+ inside_block = False
775
+
776
+ for line in lines:
777
+ stripped = line.strip()
778
+ if stripped.startswith(">list"): # start of new block
779
+ if inside_block: # close previous
780
+ output.append("```")
781
+ inside_block = False
782
+ if output and output[-1].strip():
783
+ output.append("") # blank line before
784
+ output.append("```text")
785
+ output.append(line)
786
+ inside_block = True
787
+ elif inside_block and stripped.startswith(">"): # continuation
788
+ output.append(line)
789
+ elif inside_block: # end of block
790
+ output.append("```")
791
+ inside_block = False
792
+ output.append(line)
793
+ else:
794
+ output.append(line)
795
+
796
+ if inside_block: # close any open block
797
+ output.append("```")
798
+
799
+ return "\n".join(output)
800
+
801
+
802
+ def parse_sublists(text: str) -> list[tuple[str, list[str]]]:
803
+ """
804
+ Parse a text fragment defining one or more sublists into a list of tuples.
805
+
806
+ Example:
807
+ 'sexes : female male / fertile : 1 0'
808
+ → [('sexes', ['female', 'male']), ('fertile', ['1', '0'])]
809
+ """
810
+ result = []
811
+ for part in [p.strip() for p in text.split('/') if p.strip()]:
812
+ if ':' not in part:
813
+ continue
814
+ sub, vals = [p.strip() for p in part.split(':', 1)]
815
+ result.append((sub, vals.split()))
816
+ return result
817
+
818
+
819
+ def render_list_table(list_name: str, entries: list[tuple[str, list[str]]]) -> str:
820
+ """
821
+ Render one parsed list into a Markdown table, preserving structure.
822
+
823
+ Example:
824
+ [('ages', ['age_0', '*', 'age_11'])] →
825
+ > list
826
+ | **list name** | **sublist name** ||| |
827
+ |:------------------|:-------------------------------------------|-:|-:|-:|
828
+ | ages | ages | age_0 | * | age_11 |
829
+ """
830
+ if not entries:
831
+ return ""
832
+
833
+ # Determine max number of value columns
834
+ max_cols = max(len(e[1]) for e in entries)
835
+ pipes = " |" * max_cols
836
+
837
+ # Header and alignment lines (exact Markdown structure)
838
+ header = f"\n| **list name** | **sublist name** {pipes} |\n"
839
+ header += (
840
+ f"|:------------------|:-------------------------------------------|"
841
+ + "|".join(["---:" for _ in range(max_cols)])
842
+ + "|\n"
843
+ )
844
+
845
+ # Align sublist names to the longest one for consistent look
846
+ max_sub_len = max(len(sub) for sub, _ in entries)
847
+
848
+ rows = []
849
+ first = True
850
+ for sublist, items in entries:
851
+ # Only show list name once
852
+ list_cell = list_name if first else ""
853
+ first = False
854
+
855
+ # Pad with blanks if some sublists have fewer entries
856
+ padded_items = items + [""] * (max_cols - len(items))
857
+ row = f"| {list_cell:<18}| {sublist:<{max_sub_len}} | " + " | ".join(padded_items) + " |"
858
+ rows.append(row)
859
+
860
+ return header + "\n".join(rows)
861
+ import re
862
+
863
+ def mfmod_list_to_codeblock(text: str) -> str:
864
+ """
865
+ Wrap any consecutive lines starting with '>' (including '>list' definitions)
866
+ in fenced code blocks (```text) so they render in fixed-width font
867
+ in Jupyter Book. All other lines remain unchanged.
868
+
869
+ Example
870
+ -------
871
+ Input:
872
+ >list ages = ages : age_0 * age_11
873
+ >list sexes = sexes : female male /
874
+ > fertile : 1 0
875
+ other stuff
876
+
877
+ Output:
878
+ ```text
879
+ >list ages = ages : age_0 * age_11
880
+ >list sexes = sexes : female male /
881
+ > fertile : 1 0
882
+ ```
883
+ other stuff
884
+ """
885
+ lines = text.splitlines()
886
+ output = []
887
+ inside_block = False
888
+
889
+ for line in lines:
890
+ stripped = line.strip()
891
+
892
+ # Lines starting with '>' belong to a code block
893
+ if stripped.startswith(">"):
894
+ if not inside_block:
895
+ # add a blank line before starting a block if previous line not blank
896
+ if output and output[-1].strip():
897
+ output.append("")
898
+ output.append("```text")
899
+ inside_block = True
900
+ output.append(line)
901
+ else:
902
+ # close any open block before non-'>' line
903
+ if inside_block:
904
+ output.append("```")
905
+ inside_block = False
906
+ output.append(line)
907
+
908
+ # close any unclosed block at end
909
+ if inside_block:
910
+ output.append("```")
911
+
912
+ return "\n".join(output)
913
+
914
+ import modelnormalize as nz
915
+ from functools import cached_property
916
+
917
+ def render_markdown_model(cell: str, style: str = "mixed") -> str:
918
+ """
919
+ Prepare Markdown text for rendering.
920
+
921
+ Responsibilities:
922
+ - Protect model lines (starting with '>') by wrapping them in code fences
923
+ - Leave LaTeX blocks untouched
924
+ - Do NOT render anything
925
+ """
926
+
927
+ if style == "plain":
928
+ return cell
929
+
930
+ lines = cell.splitlines()
931
+ out = []
932
+
933
+ in_code = False
934
+ in_fence = False
935
+
936
+ def start_code():
937
+ nonlocal in_code
938
+ if not in_code:
939
+ out.append("```text")
940
+ in_code = True
941
+
942
+ def end_code():
943
+ nonlocal in_code
944
+ if in_code:
945
+ out.append("```")
946
+ in_code = False
947
+
948
+ for line in lines:
949
+ stripped = line.lstrip()
950
+
951
+ # Preserve existing fenced blocks
952
+ if stripped.startswith("```"):
953
+ end_code()
954
+ out.append(stripped)
955
+ in_fence = not in_fence
956
+ continue
957
+
958
+ if in_fence:
959
+ out.append(line)
960
+ continue
961
+
962
+ # Model lines → code block
963
+ if stripped.startswith(">"):
964
+ start_code()
965
+ out.append(stripped)
966
+ continue
967
+
968
+ # Normal text
969
+ end_code()
970
+ out.append(line)
971
+
972
+ end_code()
973
+ # debug_var(out)
974
+ return "\n".join(out)
975
+
976
+ import re
977
+ from IPython.display import display, Markdown, Math
978
+
979
+
980
+ import re
981
+ from IPython.display import display, Markdown, Math
982
+
983
+ def display_model(cell: str, spec: str = "markdown"):
984
+ """
985
+ Display a model specification.
986
+
987
+ spec = "markdown" | "latex"
988
+ """
989
+ if spec == "markdown":
990
+ display_markdown_model(cell)
991
+ elif spec == "latex":
992
+ display_latex_model(cell)
993
+ else:
994
+ raise ValueError("spec must be 'markdown' or 'latex'")
995
+
996
+ def display_markdown_model(cell: str):
997
+ cell = render_markdown_model(cell)
998
+ display_mixed_markdown(cell)
999
+
1000
+
1001
+
1002
+ def markdown_headings_to_latex(text: str) -> str:
1003
+ lines = []
1004
+ for line in text.splitlines():
1005
+ if line.startswith("### "):
1006
+ lines.append(r"\subsubsection{" + line[4:] + "}")
1007
+ elif line.startswith("## "):
1008
+ lines.append(r"\subsection{" + line[3:] + "}")
1009
+ elif line.startswith("# "):
1010
+ lines.append(r"\section{" + line[2:] + "}")
1011
+ else:
1012
+ lines.append(line)
1013
+ return "\n".join(lines)
1014
+
1015
+ def protect_model_code_latex(md: str) -> str:
1016
+ out = []
1017
+ in_code = False
1018
+
1019
+ for line in md.splitlines():
1020
+ if line.lstrip().startswith(">"):
1021
+ if not in_code:
1022
+ out.append(r"\begin{verbatim}")
1023
+ in_code = True
1024
+ out.append(line.lstrip()[1:])
1025
+ else:
1026
+ if in_code:
1027
+ out.append(r"\end{verbatim}")
1028
+ in_code = False
1029
+ out.append(line)
1030
+
1031
+ if in_code:
1032
+ out.append(r"\end{verbatim}")
1033
+
1034
+ return "\n".join(out)
1035
+
1036
+ from IPython.display import Latex, display
1037
+
1038
+ def display_latex_model(cell: str):
1039
+ # cell = protect_model_code_latex(cell)
1040
+
1041
+ display(Markdown(cell))
1042
+
1043
+
1044
+
1045
+ def display_mixed_markdown(md: str):
1046
+ """
1047
+ Display Markdown mixed with LaTeX safely in Jupyter.
1048
+
1049
+ Supports unlimited alternation of:
1050
+ - Markdown prose
1051
+ - LaTeX equation / align environments
1052
+ - $$ ... $$ blocks
1053
+
1054
+ Rendering rules:
1055
+ - Math() receives ONLY pure math (no environments, no labels, no tags)
1056
+ - Markdown() never sees LaTeX display environments
1057
+ """
1058
+
1059
+ pattern = re.compile(
1060
+ r"\$\$(.*?)\$\$"
1061
+ r"|\\begin\{equation\}(.*?)\\end\{equation\}"
1062
+ r"|\\begin\{align\}(.*?)\\end\{align\}",
1063
+ flags=re.DOTALL,
1064
+ )
1065
+
1066
+ pos = 0
1067
+
1068
+ def _clean_math_body(math: str) -> str:
1069
+ """
1070
+ Remove non-math directives from LaTeX before MathJax rendering.
1071
+ """
1072
+ LABEL_RE = re.compile(r"\\label\{[^}]*\}")
1073
+ TAG_RE = re.compile(r"%\s*@<[^>]*>")
1074
+ COMMENT_RE = re.compile(r"%.*?$", re.MULTILINE)
1075
+
1076
+ math = LABEL_RE.sub("", math)
1077
+ math = TAG_RE.sub("", math)
1078
+ math = COMMENT_RE.sub("", math)
1079
+ return math.strip()
1080
+
1081
+
1082
+
1083
+ for m in pattern.finditer(md):
1084
+ # ---- Markdown before math ----
1085
+ if m.start() > pos:
1086
+ text = md[pos:m.start()]
1087
+ if text.strip():
1088
+ display(Markdown(text))
1089
+
1090
+ # ---- Math block ----
1091
+ raw_math = next(g for g in m.groups() if g is not None)
1092
+ clean_math = _clean_math_body(raw_math)
1093
+ if clean_math:
1094
+ display(Math(clean_math))
1095
+
1096
+ pos = m.end()
1097
+
1098
+ # ---- Trailing Markdown ----
1099
+ rest = md[pos:]
1100
+ if rest.strip():
1101
+ display(Markdown(rest))
1102
+
1103
+
1104
+
1105
+ @dataclass
1106
+ class BaseExplode:
1107
+ """Common parent for Mexplode and Lexplode."""
1108
+ original_statements : str = field(default="", metadata={"description": "Input expressions"})
1109
+ normal_frml : str = field(default="",init=False, metadata={"description": "Output normalized expressions"})
1110
+ markdown_model : str = field(default="",init=False, metadata={"description": "As markdown"})
1111
+ funks : List[Any] = field(default_factory=list, metadata={"description": "List of user specified functions to be used in model"})
1112
+
1113
+
1114
+
1115
+ @property
1116
+ def show(self):
1117
+ print (self.normal_frml.strip())
1118
+
1119
+ @property
1120
+ def draw(self):
1121
+ self.mmodel.drawmodel()
1122
+
1123
+ @cached_property
1124
+ def mmodel(self):
1125
+ from modelclass import model
1126
+ return model(self.normal_frml,funks=self.funks)
1127
+
1128
+ def __str__(self):
1129
+ return self.normal_frml.strip()
1130
+
1131
+ @property
1132
+ def render(self):
1133
+ # display(Markdown(self.markdown_model))
1134
+
1135
+ rendered_md = render_markdown_model(self.markdown_model)
1136
+ display_mixed_markdown(rendered_md)
1137
+
1138
+ @dataclass
1139
+ class Mexplode(BaseExplode):
1140
+ # original_statements : str = field(default="", metadata={"description": "Input expressions"})
1141
+ # normal_frml : str = field(default="", metadata={"description": "Output normalized expressions"})
1142
+
1143
+ normal_main : str = field(init=False, metadata={"description": "Normalized frmls"})
1144
+ normal_fit : str = field(init=False, metadata={"description": "Normalized frmls for fitted values"})
1145
+ normal_calc_add : str = field(init=False, metadata={"description": "Normalized frmls to calculate add factors"})
1146
+ replacements : Union[tuple[str, str], list[tuple[str, str]]] = field(default_factory=list, metadata={"description": "list of string tupels with string replacements"})
1147
+ list_defs : str = field(default="", metadata={"description": "Lists definitions"})
1148
+ modelname : str = field(default="", metadata={"description": "A optional name for this (sub) model"})
1149
+
1150
+ clean_frml_statements : str = field(init=False, metadata={"description": "With frml and nice lists"})
1151
+ post_doable : str = field(init=False, metadata={"description": "Expanded after doable"})
1152
+ post_do : str = field(init=False, metadata={"description": "Frmls after do expansion"})
1153
+ post_sum : str = field(init=False, metadata={"description": "Frmls after expanding sums"})
1154
+ expanded_frml : str = field(init=False, metadata={"description": "frmls after expanding "})
1155
+ normal_expressions : List[Any] = field(init=False, metadata={"description": "List of normal expressions"})
1156
+ list_specification : str = field(init=False, metadata={"description": "All list specifications in string "})
1157
+ modellist : str = field(init=False, metadata={"description": "The lists defined in string as a dictionary"})
1158
+ type_input : str = field(default= 'markdown' ,metadata={"description": "Originaal as type modelflow or markdown"})
1159
+
1160
+ def __post_init__(self):
1161
+ '''prepares a model from a model template.
1162
+
1163
+ Returns a expanded model which is ready to solve
1164
+
1165
+ Eksempel: model = udrul_model(MinModel.txt)'''
1166
+
1167
+ if self.type_input == 'markdown':
1168
+ extracted_frml = extract_model_from_markdown(self.list_defs+self.original_statements)
1169
+ self.markdown_model = (mfmod_list_to_codeblock(self.original_statements))
1170
+
1171
+ else:
1172
+ extracted_frml = self.list_defs+self.original_statements
1173
+ self.markdown_model = ''
1174
+
1175
+ self.clean_frml_statements = clean_expressions(
1176
+ apply_replacements(extracted_frml,self.replacements)
1177
+ ).strip().upper()
1178
+ # debug_var(extract_model_from_markdown(self.original_statements))
1179
+
1180
+
1181
+ self.post_doable = doable_unroll(self.clean_frml_statements ,self.funks)
1182
+ # assert 1==2
1183
+ self.post_do = dounloop(self.post_doable) # we unroll the do loops
1184
+
1185
+ self.modellist = list_extract(self.post_do)
1186
+ self.post_sum = sumunroll(self.post_do,listin=self.modellist) # then we unroll the sum
1187
+
1188
+ self.expanded_frml = self.post_sum
1189
+ self.expanded_frml_list = find_frml(self.expanded_frml)
1190
+ self.expanded_frml_split = [split_frml_reqopts(frml) for frml in self.expanded_frml_list]
1191
+
1192
+ # def normal(ind_o,the_endo='',add_add_factor=True,do_preprocess = True,add_suffix = '_A',endo_lhs = True, =False,make_fitted=False,eviews=''):
1193
+ self.normal = [(parts,
1194
+ nz.normal(parts.expression,
1195
+ add_add_factor= kw_frml_name(parts.frmlname, 'ADD') or kw_frml_name(parts.frmlname, 'STOC'),
1196
+ add_suffix = kw_frml_name(parts.frmlname, 'ADD_SUFFIX','_A'),
1197
+ make_fixable = kw_frml_name(parts.frmlname, 'EXO')or kw_frml_name(parts.frmlname, 'STOC'),
1198
+ make_fitted = kw_frml_name(parts.frmlname, 'FIT'),
1199
+ the_endo = kw_frml_name(parts.frmlname, 'ENDO'),
1200
+ endo_lhs = False if 'FALSE' == kw_frml_name(parts.frmlname, 'ENDO_LHS',default='1') else True,
1201
+ implicit = kw_frml_name(parts.frmlname, 'IMPLICIT')
1202
+ )
1203
+ )
1204
+
1205
+ for parts in self.expanded_frml_split]
1206
+
1207
+ self.normal_expressions = [n for p,n in self.normal ]
1208
+
1209
+ # udrullet = lagarray_unroll(udrullet,funks=funks )
1210
+ # udrullet = creatematrix(udrullet,listin=modellist)
1211
+ # udrullet = createarray(udrullet,listin=modellist)
1212
+ # udrullet = argunroll(udrullet,listin=modellist)
1213
+ self.normal_main = '\n'.join([f'FRML {parts.frmlname} {normal.normalized} $'
1214
+ for parts, normal in self.normal
1215
+ ])
1216
+ self.normal_fit = '\n'.join([f'FRML <FIT> {normal.fitted } $ '
1217
+ for parts, normal in self.normal if len(normal.fitted)
1218
+ ])
1219
+ self.normal_calc_add = '\n'.join([f'FRML <CALC_ADD_FACTOR> {normal.calc_add_factor } $ '
1220
+ for parts, normal in self.normal if len(normal.calc_add_factor)
1221
+ ])
1222
+
1223
+ self.normal_frml = '\n'.join( [self.normal_main,self.normal_fit,self.normal_calc_add])
1224
+
1225
+ check_syntax_model(self.normal_frml)
1226
+
1227
+ self.list_specification = self.get_lists()
1228
+ return
1229
+
1230
+
1231
+ def get_lists (self) -> str:
1232
+ """
1233
+ Extract all LIST statements (ending with $) from clean_frml_statements.
1234
+ Returns them as a single string, separated by newlines.
1235
+ """
1236
+ if not getattr(self, "clean_frml_statements", ""):
1237
+ return ""
1238
+
1239
+ # split by '$' and strip spaces/newlines
1240
+ statements = [stmt.strip() + " $"
1241
+ for stmt in self.clean_frml_statements.split("$")
1242
+ if stmt.strip()]
1243
+
1244
+ # filter for LIST statements
1245
+ list_statements = [stmt for stmt in statements if stmt.upper().startswith("LIST ")]
1246
+ return "\n".join(list_statements)
1247
+
1248
+
1249
+
1250
+
1251
+ @property
1252
+ def showlists(self):
1253
+ print ( pformat(self.modellist, width=100, compact=False))
1254
+
1255
+ @property
1256
+ def showmodellist(self):
1257
+ print ( pformat(self.modellist, width=100, compact=False))
1258
+
1259
+
1260
+ def pdf(self,**kwargs):
1261
+ pre= r'''
1262
+ {\setlength{\parskip}{1em}
1263
+ \setlength{\parindent}{0pt}
1264
+ '''
1265
+
1266
+ return LatexRepo(fr'{pre} {self.original_statements} }}').pdf(**kwargs)
1267
+
1268
+ def __getattr__(self, attr):
1269
+ if attr.startswith("show"):
1270
+ prop = attr[4:].lower()
1271
+ field_defs = fields(self)
1272
+ fieldnames = [f.name for f in field_defs]
1273
+
1274
+ if prop in fieldnames:
1275
+ value = getattr(self, prop)
1276
+ if isinstance(value, list) and all(isinstance(v, nz.Normalized_frml) for v in value):
1277
+ print(f"\n--- {prop.upper()} ({len(value)} items) ---")
1278
+ for i, frml in enumerate(value, 1):
1279
+ print(f"\n[{i}]")
1280
+ print(frml) # uses Normalized_frml.__str__
1281
+ else:
1282
+
1283
+ print(f"{prop.capitalize()}: \n{value}")
1284
+ else:
1285
+ import inspect
1286
+ # --- guess varname only if property not found ---
1287
+ varname = self.__class__.__name__.lower()
1288
+ frame = inspect.currentframe()
1289
+ try:
1290
+ caller = frame.f_back if frame else None
1291
+ if caller:
1292
+ varname = next((k for k, v in caller.f_locals.items() if v is self), varname)
1293
+ finally:
1294
+ del frame
1295
+ try:
1296
+ del caller
1297
+ except NameError:
1298
+ pass
1299
+ # -------------------------------------------------
1300
+
1301
+
1302
+ left_parts = [f"{varname}.show{f.name}" for f in field_defs]
1303
+ maxlen = max(len(lp) for lp in left_parts)
1304
+ options = "\n".join(
1305
+ f"{lp.ljust(maxlen)} # {f.metadata.get('description')}"
1306
+ if f.metadata.get("description")
1307
+ else lp
1308
+ for lp, f in zip(left_parts, field_defs)
1309
+ )
1310
+
1311
+ print(f"No such property '{prop}'. Try one of:\n{options}")
1312
+ return None
1313
+
1314
+ raise AttributeError(f"'{type(self).__name__}' object has no attribute '{attr}'")
1315
+
1316
+ def __add__ (self, other):
1317
+ if isinstance(other, Mexplode):
1318
+ return Lexplode(mexplodes=[self, other])
1319
+ elif isinstance(other, Lexplode):
1320
+ return Lexplode(mexplodes= [self] + other.mexplodes)
1321
+ else:
1322
+ return NotImplemented
1323
+
1324
+ def __radd__(self, other):
1325
+ # handle reversed order
1326
+ if other == 0:
1327
+ return self
1328
+ if isinstance(other, Mexplode):
1329
+ return Lexplode(mexplodes=[other, self])
1330
+ elif isinstance(other, Lexplode):
1331
+ return Lexplode(mexplodes=other.mexplodes + [self])
1332
+ else:
1333
+ return NotImplemented
1334
+
1335
+
1336
+ def __repr__(self) -> str:
1337
+ def fmt(value: Any) -> str:
1338
+ # Show multiline strings as real blocks (no quotes)
1339
+ if isinstance(value, str) and '\n' in value:
1340
+ return "\n" + textwrap.indent(value.rstrip(), " ")
1341
+ # Pretty containers
1342
+ if isinstance(value, (list, tuple, set, dict)):
1343
+ return pformat(value, width=100, compact=False)
1344
+ # defaultdict / custom objects get pformat fallback via repr-str mix:
1345
+ try:
1346
+ from collections import defaultdict
1347
+ if isinstance(value, defaultdict):
1348
+ return pformat(value, width=100, compact=False)
1349
+ except Exception:
1350
+ pass
1351
+ # Everything else
1352
+ return repr(value)
1353
+
1354
+ items = vars(self)
1355
+ w = max(len(k) for k in items) if items else 0
1356
+ lines = []
1357
+ for k, v in items.items():
1358
+ rendered = fmt(v)
1359
+ if rendered.startswith("\n"): # multiline block
1360
+ lines.append(f"{k:<{w}} :{rendered}")
1361
+ else:
1362
+ lines.append(f"{k:<{w}} : {rendered}")
1363
+ return f"{self.__class__.__name__}(\n " + "\n ".join(lines) + "\n)"
1364
+
1365
+ # -*- coding: utf-8 -*-
1366
+ """
1367
+ Extension to modelconstruct.py
1368
+ Adds Lexplode dataclass and addition support for Mexplode and Lexplode.
1369
+ """
1370
+
1371
+
1372
+
1373
+ @dataclass
1374
+ class Lexplode(BaseExplode):
1375
+ """Container for multiple Mexplode instances.
1376
+
1377
+ Enables combination of several model explosions using + operations.
1378
+ """
1379
+
1380
+ mexplodes: List['Mexplode'] = field(default_factory=list)
1381
+
1382
+
1383
+ # def __post_init__(self):
1384
+
1385
+ # self.normal_frml = '\n'.join(m.normal_frml.strip() for m in self.mexplodes)
1386
+
1387
+ @cached_property
1388
+ def normal_frml(self) -> str:
1389
+ """Compute and cache concatenated normal_frml lazily on first access."""
1390
+ return "\n".join(m.normal_frml.strip() for m in self.mexplodes)
1391
+
1392
+
1393
+ def __add__(self, other):
1394
+ if isinstance(other, Mexplode):
1395
+ return Lexplode(mexplodes=self.mexplodes + [other])
1396
+ elif isinstance(other, Lexplode):
1397
+ return Lexplode(mexplodes=self.mexplodes + other.mexplodes)
1398
+ else:
1399
+ return NotImplemented
1400
+
1401
+ def __radd__(self, other):
1402
+ if other == 0:
1403
+ return self
1404
+ if isinstance(other, Mexplode):
1405
+ return Lexplode(mexplodes=[other] + self.mexplodes)
1406
+ elif isinstance(other, Lexplode):
1407
+ return Lexplode(mexplodes=other.mexplodes + self.mexplodes)
1408
+ else:
1409
+ return NotImplemented
1410
+
1411
+
1412
+ def __repr__(self):
1413
+ return f"Lexplode(mexplodes={self.mexplodes!r})"
1414
+
1415
+ # __str__(self):
1416
+
1417
+
1418
+
1419
+
1420
+ if __name__ == '__main__' :
1421
+ #%%
1422
+ pass
1423
+ if 0:
1424
+ res3 = Mexplode('''
1425
+ <endo=f,stoc> a = gamma+ f+O
1426
+ <endo=f> a = gamma+ f+O
1427
+ <endo=x,stoc,endo_lhs> a+x = gamma+ f+O
1428
+ <endo=x,stoc> a+x = gamma+ f+O
1429
+ <fit,exo,stoc> c = b*42
1430
+
1431
+ ''',replacements=None)
1432
+
1433
+ res3.shownormal_expressions
1434
+ tlists = '''
1435
+ LIST BANKS = BANKS : IB SOREN MARIE /
1436
+ COUNTRY : DENMARK SWEDEN DENMARK /
1437
+ SELECTED : 1 0 1
1438
+ $
1439
+ LIST SECTORS = SECTORS : NFC SME HH $
1440
+
1441
+ '''
1442
+
1443
+ xx = '''
1444
+ doable <HEST,sum=abe> [banks=country=sweden, sektors=sektors] LOSS__{BANKS}__{SECTORs} = HOLDING__{BANKS}__{SECTORs} * PD__{BANKS}__{SECTORs}$
1445
+ doable <HEST,sum=goat> [banks=country=denmark,sektors=sektors] LOSS2__{BANKS}__{SECTORs} = HOLDING__{BANKS}__{SECTORs} * PD__{BANKS}__{SECTORs}$
1446
+ do sectors $
1447
+ frml <> x_{sectors} = 42 $
1448
+ enddo $
1449
+
1450
+ '''.upper()
1451
+ # xx = 'doabel <HEST,sum=abe> LOSS__{BANKS}__{SECTORs} =HOLDING__{BANKS}__{SECTORs} * PD__{BANKS}__{SECTORs} $'.upper()
1452
+
1453
+ # res = Mexplode(tlists+xx)
1454
+ # print(res)
1455
+ # breakpoint()
1456
+ res2 = Mexplode('''
1457
+ LIST BANKS = BANKS : IB SOREN MARIE /
1458
+ COUNTRY : DENMARK SWEDEN DENMARK /
1459
+ SELECTED : 1 1 0
1460
+ LIST SECTORS = SECTORS : NFC SME HH
1461
+ LIST test = t : 100*104
1462
+ a = b
1463
+ c = 33
1464
+ £ test
1465
+ doable <HEST,sum=goat> [banks country=denmark] LOSS2__{BANKS}__{SECTORs} = HOLDING__{BANKS}__{SECTORs} * PD__{BANKS}__{SECTORs}
1466
+ do banks
1467
+ £ sector {country}
1468
+ frml <> x_{banks} = 42
1469
+ enddo
1470
+
1471
+ ''')
1472
+
1473
+ print(res2+res3)
1474
+
1475
+ if 0:
1476
+ test1= Mexplode('a=1')
1477
+ test2= Mexplode('b=2')
1478
+ test3= Mexplode('c=3')
1479
+ print(test1+(test2+test3))
1480
+ print(test1)
1481
+
1482
+ text = """>list ages = ages : age_0 * age_11
1483
+ >list sexes = sexes : female male /
1484
+ > fertile : 1 0
1485
+ other stuf
1486
+ and yes
1487
+ > dekdkd"""
1488
+
1489
+
1490
+
1491
+ print(text2:=mfmod_list_to_markdown(text))
1492
+
1493
+ #%% latex
1494
+ leq = r'CRF^{l,es,d}=\frac{DiscountRate^{l,es,d}}{1 - \left(1 + DiscountRate^{l,es,d}\right)^{-LifeSpan^{l,es,d}}}'
1495
+ meq = latex_to_doable(leq, '<ii>')
1496
+ print(meq)