Differences

This shows you the differences between two versions of the page.

Link to this comparison view

Both sides previous revision Previous revision
Next revision
Previous revision
manipulating_tables_and_lookup_tables [2026/07/28 19:56]
hermann
manipulating_tables_and_lookup_tables [2026/08/18 03:09] (current)
hermann [Printing a table with any number of key columns]
Line 298: Line 298:
 ==== SetTableCellValue ==== ==== SetTableCellValue ====
  
-Writes a single cell — the write counterpart to ''​GetTableValue''​, though its ''​column''​ and ''​keys''​ arguments come in the opposite order.+Writes a single cell — the write counterpart to ''​GetTableValue''​.
  
 ^ Port ^ Direction ^ Type ^ Required? ^ Description ^ ^ Port ^ Direction ^ Type ^ Required? ^ Description ^
Line 495: Line 495:
 doubledResults := % [ %sourceLookup[line] * 2 ] "​CityId"​ "​DoubledPopulation"​ sourceLookup;​ doubledResults := % [ %sourceLookup[line] * 2 ] "​CityId"​ "​DoubledPopulation"​ sourceLookup;​
 </​code>​ </​code>​
 +
 +One difference in shape: the loop accumulates into a Table, since ''​emptyResults''​ was declared as one, while ''​%''​ always returns a Lookup Table. Where the Table form is the one wanted, a ''​Table''​ carrier converts it — ''​asTable := Table doubledResults''​ — one Real key and one Real value fitting both types equally well.
 +
 +===== Table Manager =====
 +
 +The accumulator pattern in [[#​storing_results_across_a_loop|Storing results across a loop]] uses a Mux to carry a growing table from one iteration to the next -- which, as that section'​s closing note explains, forces the loop to run sequentially,​ one iteration at a time. **Table Manager** solves the same class of problem -- building a table with an extra key column from results computed across a loop -- without that cost: each iteration registers its own sub-table independently,​ with no Mux and nothing threaded between iterations, so the loop producing those partial results stays eligible to run in parallel, per [[basic_data_flow|Basic Data Flow]]. A single [[#​mergesubtables|MergeSubTables]] call combines everything afterward.
 +
 +A container and two functors do the core work; a fourth covers a narrower case:
 +
 +==== TableManager ====
 +
 +A container functor. It creates the manager instance and exposes it to whatever runs inside it.
 +
 +^ Port ^ Direction ^ Type ^ Description ^
 +| ''​tableManager''​ | Internal Output | TableManager | The manager instance. Auto-binds to the ''​tableManager''​ input of [[#​subtable|SubTable]] and [[#​mergesubtables|MergeSubTables]] calls nested anywhere inside this container, including inside further nested containers such as a [[For]] loop or a [[Group]]. |
 +
 +The container itself takes no inputs and defines no regular outputs.
 +
 +==== SubTable ====
 +
 +Registers one sub-table under a name and a key.
 +
 +^ Port ^ Direction ^ Type ^ Required? ^ Description ^
 +| ''​tableName''​ | Input | Name | Yes | Identifies which set of sub-tables this one joins. |
 +| ''​keys''​ | Input | Tuple | Yes | The key identifying this sub-table within the set. |
 +| ''​subTable''​ | Input | Table | Yes | The sub-table itself. |
 +| ''​emptySubTableIsAllowed''​ | Input | Boolean | No | Whether an empty sub-table is accepted rather than raising an error. Default ''​.yes''​. |
 +| ''​tableManager''​ | Input | TableManager | Yes | Auto-binds to the enclosing [[#​tablemanager|TableManager]] container; normally left unconnected. |
 +| ''​object''​ | Output | Table | -- | The same sub-table passed in, returned unchanged so it can still be referenced or connected onward. |
 +
 +Every ''​SubTable''​ call sharing a ''​tableName''​ is checked against the first one registered under that name: the ''​keys''​ tuple must have the same element types, and ''​subTable''​ must have the same columns. A mismatch, or registering the same ''​keys''​ twice under the same ''​tableName'',​ raises an error.
 +
 +==== MergeSubTables ====
 +
 +Combines every sub-table registered under a name into a base table, then discards them from the manager.
 +
 +^ Port ^ Direction ^ Type ^ Required? ^ Description ^
 +| ''​baseTable''​ | Input | Table | Yes | The table the combined sub-tables are inserted into. |
 +| ''​tableName''​ | Input | Name | Yes | Which set of sub-tables to merge. |
 +| ''​allowNamesWithNoAssociatedSubTables''​ | Input | Boolean | No | If ''​.no'',​ merging a name nothing was ever registered under raises an error; if ''​.yes'',​ it's silently skipped. Default ''​.no''​. |
 +| ''​tableManager''​ | Input | TableManager | Yes | Auto-binds to the enclosing [[#​tablemanager|TableManager]] container. |
 +| ''​table''​ | Output | Table | -- | ''​baseTable''​ with every sub-table registered under ''​tableName''​ inserted, each keyed by the Tuple it was registered with. |
 +
 +Each sub-table'​s ''​keys''​ tuple can hold more than one element; every element becomes its own leading key column on ''​baseTable''​. A ''​tableName''​ can only be merged once -- the merge removes its sub-tables from the manager as it runs.
 +
 +> **Warning:​** ''​MergeSubTables''​ has no data dependency on the ''​SubTable''​ calls that feed it -- its ''​baseTable''​ input, its literal ''​tableName'',​ and its auto-bound manager are all available from the moment the container starts, and none of them depend on the loop that registered the sub-tables. Placing it after a loop in the script, even inside the same ''​TableManager''​ container, does **not** guarantee it runs after that loop finishes; the two have no connection, so the engine is free to run them in either order or at the same time. Force the ordering explicitly with a [[Group]] gated by the loop's ''​sequenceOutput'',​ as in the example below -- see [[basic_data_flow#​ordering_without_data_sequence_connections|Ordering without data: sequence connections]] and [[ego_script#​sequence_ports|Sequence ports]].
 +
 +==== TableManagerValue ====
 +
 +An explicit pass-through for a TableManager instance, for the rare case where auto-bind doesn'​t reach -- for instance, threading a manager through a submodel boundary as a named port rather than relying on ambient container nesting. Within a single model, auto-bind on ''​SubTable''​ and ''​MergeSubTables''​ makes this unnecessary.
 +
 +^ Port ^ Direction ^ Type ^ Required? ^ Description ^
 +| ''​tableManager''​ | Input | TableManager | No | Auto-binds to the enclosing [[#​tablemanager|TableManager]] container. |
 +| ''​tableManager''​ | Output | TableManager | -- | The same instance, under an explicit name. |
 +
 +==== Example ====
 +
 +Building a two-key table from per-year sub-tables computed inside a loop, with the merge correctly sequenced after the loop:
 +
 +<​code>​
 +emptyPriceTable := Table [ "​Year*#​real",​ "​City*#​string",​ "​Price#​real"​ ];
 +
 +TableManager {{
 +    // forDone carries no data of its own; it exists purely so the Group
 +    // below can force itself to wait for every iteration of this loop.
 +    forDone := For 2004 2007 {{
 +        year := Step;
 +
 +        // Stand-in for a per-year computation;​ in practice this might come
 +        // from a database query, a Calculate Table Values call, or another
 +        // model entirely. A Table literal only accepts literal cell values,
 +        // so a real per-year computation would build this row by row with
 +        // AddTupleValue and AddTableRow instead, the same as any table
 +        // whose contents aren't known until the model runs.
 +        yearPrices := Table [
 +            "​City*",​ "​Price",​
 +            "​Boston",​ 4000,
 +            "​Chelsea",​ 200
 +        ];
 +
 +        _ := SubTable "​prices"​ year yearPrices;
 +    }};
 +
 +    // Group'​s sequenceInput,​ fed from the loop's sequenceOutput,​ forces
 +    // this Group -- and MergeSubTables inside it -- to wait for the whole
 +    // loop to finish, without threading a Mux through its iterations.
 +    _ := Group forDone {{
 +        priceTable := MergeSubTables emptyPriceTable "​prices";​
 +    }};
 +}};
 +</​code>​
 +
 +''​priceTable''​ is usable here, once the Group it was produced inside — and through its ''​sequenceInput'',​ the loop that Group was forced to wait for — has finished.
 +
 +The loop's own iterations are still eligible to run in parallel with each other: nothing inside it is a Mux, nothing inside it is consumed from outside it, and it feeds no submodel output port -- see [[basic_data_flow|Basic Data Flow]]. Only the loop as a whole is sequenced against the merge, which is why this avoids the cost the Mux-accumulator pattern above pays on every single iteration.
 +
 +> **Note:** ''​year''​ is passed to ''​SubTable''​ directly, not wrapped in brackets as ''​[year]''​. Bracket syntax is for Tuple **literals**;​ a connected Real value converts to a one-element Tuple automatically on connection, the same pattern used by ''​GetTableFromKey''​ in [[#​sub-tables|Sub-Tables]] above.
 +
  
 ===== Printing a table with a generic format ===== ===== Printing a table with a generic format =====
Line 502: Line 600:
 [[Get Table Row]], [[Get Tuple Size]], and [[Get Tuple Value]] make the column-count side of this possible: a row read as a Tuple can be measured with ''​GetTupleSize'',​ and each of its elements retrieved by position, rather than by column name, with ''​GetTupleValue''​. Each retrieved element has type ''​TableValue''​ — a generic type able to hold either a Real or a String. [[Get Table Row]], [[Get Tuple Size]], and [[Get Tuple Value]] make the column-count side of this possible: a row read as a Tuple can be measured with ''​GetTupleSize'',​ and each of its elements retrieved by position, rather than by column name, with ''​GetTupleValue''​. Each retrieved element has type ''​TableValue''​ — a generic type able to hold either a Real or a String.
  
-[[String]] and [[Real Value]] each convert a ''​TableValue''​ the same conditional way: the conversion only succeeds if the ''​TableValue''​ actually holds that type, and raises an error otherwise — converting an already-typed,​ known ''​RealValue''​ or String, by contrast, never fails. ​Attempting the String conversion wrapped ​in [[Skip On Error]] turns that failure into a type test: ''​SkipOnError''​ catches the error rather than aborting the model, ​and its ''​executionCompletedSucessfully''​ output reports whether ​the ''​TableValue'' ​really was String. [[If Then]] and [[If Not Then]] branch on that result and take the matching path. The first uses the converted String directly. The second has already ruled String outso it extracts a ''​RealValue'' ​instead — safe nowsince only Real remains possible — and converts //that// known value to a stringA [[String Junction]] then recombines ​the two branches into one resultsince only one of them ever actually runs.+[[String]] and [[Real Value]] each convert a ''​TableValue''​ the same conditional way: the conversion only succeeds if the ''​TableValue''​ actually holds that type, and raises an error otherwise — converting an already-typed,​ known ''​RealValue''​ or String, by contrast, never fails. ​Wrapping each attempt ​in its own [[Skip On Error]] turns that failure into "​produced nothing"​ instead of aborting the model, ​so only the attempt that actually matches ​the ''​TableValue''​'s real type ever produces ​display string; the other attempt simply fails silently and produces nothing[[String Junction]] then picks whichever one exists. 
 + 
 +> **Note:** A junction always tries its first port before its second — see [[ego_script#​carrying_and_selecting_values_across_iterations|Carrying and selecting values across iterations]] for this rule stated generallysince it applies identically to every Junction functor (''​String'', ​''​Table'',​ ''​Value'',​ ''​Lookup Table'',​ ''​Map'',​ ''​Categorical Map'',​ ''​Folder''​)It makes no practical difference here, though: ​the two attempts below are mutually exclusive by constructionso only one of ''​stringDisplay''/''​realDisplay''​ is ever available for the junction to pick regardless of port order.
  
 <​code>​ <​code>​
Line 528: Line 628:
             displayColumn := $ [ $columnIndex - 1 ];             displayColumn := $ [ $columnIndex - 1 ];
  
-            // Attempt to read the cell as a String; SkipOnError ​catches the +            // Try the cell as a String; SkipOnError ​leaves stringDisplay 
-            // failure ​if it'​s ​actually a Real, rather than aborting. +            // unproduced ​if cellValue is actually a Real, rather than 
-            ​attempt ​:= SkipOnError .yes {{ +            // aborting ​the model
-                ​asString ​:= String cellValue+            ​:= SkipOnError .yes {{ 
-            }}; +                ​stringDisplay ​:= String cellValue;
- +
-            _ := IfThen attempt {{ +
-                stringDisplay := String asString;+
             }};             }};
  
-            ​_ := IfNotThen attempt {{ +            // Try the cell as a Real instead; ​SkipOnError leaves 
-                ​// cellValue was a Real instead; ​RealValue extracts it, and +            // realDisplay unproduced if cellValue is actually a String. 
-                // String ​— safe here, since converting a known Real never +            _ := SkipOnError ​.yes {{
-                // fails — turns it into a display string.+
                 asReal := RealValue cellValue;                 asReal := RealValue cellValue;
                 realDisplay := String asReal;                 realDisplay := String asReal;
             }};             }};
  
-            // Only one of stringDisplay/​realDisplay was actually produced+            // Exactly ​one of the two attempts above ever succeeds
-            // StringJunction picks whichever one has a value.+            // StringJunction picks whichever one was produced.
             cellDisplay := StringJunction stringDisplay realDisplay;​             cellDisplay := StringJunction stringDisplay realDisplay;​
  
Line 693: Line 789:
  
 Four muxes are threaded through this loop, at two nesting levels. Inside a station, two separate ''​MuxValue''​ passes sum that station'​s own daily values — one for the average, a second for the squared deviations from it, since the standard deviation formula needs the average already known before it can compute those deviations. Outside them, two ''​MuxLookupTable''​ accumulators carry the growing results across stations, one per statistic. [[Set Lookup Table Value]] is what actually inserts each computed value into its growing table — the write counterpart to ''​GetLookupTableValue''​. ''​RealValue cellValue''​ is used directly here, without the ''​SkipOnError''​ type test from the generic printer — this example assumes every data column is genuinely numeric, which is reasonable for daily measurements,​ unlike the printer, which had to handle either type. The second pass's variable names all carry a ''​dev''​ marker (''​devDayIndex'',​ ''​devCellValue'',​ ''​devDailyValue'',​ ''​devRunningSum'',​ ''​nextDevRunningSum''​) to keep them clearly distinct from the first pass'​s,​ even though each ''​For''​ loop is its own separate container. Four muxes are threaded through this loop, at two nesting levels. Inside a station, two separate ''​MuxValue''​ passes sum that station'​s own daily values — one for the average, a second for the squared deviations from it, since the standard deviation formula needs the average already known before it can compute those deviations. Outside them, two ''​MuxLookupTable''​ accumulators carry the growing results across stations, one per statistic. [[Set Lookup Table Value]] is what actually inserts each computed value into its growing table — the write counterpart to ''​GetLookupTableValue''​. ''​RealValue cellValue''​ is used directly here, without the ''​SkipOnError''​ type test from the generic printer — this example assumes every data column is genuinely numeric, which is reasonable for daily measurements,​ unlike the printer, which had to handle either type. The second pass's variable names all carry a ''​dev''​ marker (''​devDayIndex'',​ ''​devCellValue'',​ ''​devDailyValue'',​ ''​devRunningSum'',​ ''​nextDevRunningSum''​) to keep them clearly distinct from the first pass'​s,​ even though each ''​For''​ loop is its own separate container.
- 
-Those muxes disqualify this loop from parallel execution — see [[basic_data_flow#​the_one_exception_feedback_in_loops|The one exception: feedback in loops]] — consistent with every mux-based accumulator already documented on this page. 
  
 ==== Looping column by column ==== ==== Looping column by column ====
Line 719: Line 813:
     runningSums := MuxLookupTable initialSums nextRunningSums;​     runningSums := MuxLookupTable initialSums nextRunningSums;​
     // Adds this day's value for each station to that station'​s     // Adds this day's value for each station to that station'​s
-    // running total, computed for every station in one call.+    // running total, computed for every station in one call. The [2] 
 +    // names dayColumn'​s single data column explicitly; it can be left 
 +    // out — [[line]] alone reads the first data column by default.
     nextRunningSums := % [ column + %dayColumn[[line][2]] ] "​StationId"​ "​Sum"​ runningSums;​     nextRunningSums := % [ column + %dayColumn[[line][2]] ] "​StationId"​ "​Sum"​ runningSums;​
 }}; }};
Line 743: Line 839:
 </​code>​ </​code>​
  
-''​firstStationId''​ picks an arbitrary station just to measure the row shape once, the same ''​GetTableRow''/''​GetTupleSize''​ technique as above, minus 1 for the key — every row has the same number of columns, so any one station works. ''​initialSums''​ starts every station at zero via a single-call ''​%'',​ evaluated once per key of ''​allStationIds''​ — the base lookup table supplying the station keys — with no other operand referenced. Inside the first loop, ''​%dayColumn<​nowiki>​[[line][2]]</​nowiki>''​ reads the current day's value for the row currently being computed — the multi-column operator, since ''​dayColumn''​ is a Table, not a Lookup Table — while ''​column''​ reads that same row's running total from ''​runningSums'',​ the base table the whole expression is evaluated against.+''​firstStationId''​ picks an arbitrary station just to measure the row shape once, the same ''​GetTableRow''/''​GetTupleSize''​ technique as above, minus 1 for the key — every row has the same number of columns, so any one station works. ''​initialSums''​ starts every station at zero via a single-call ''​%'',​ evaluated once per key of ''​allStationIds''​ — the base lookup table supplying the station keys — with no other operand referenced. Inside the first loop, ''​%dayColumn<​nowiki>​[[line][2]]</​nowiki>''​ reads the current day's value for the row currently being computed — the multi-column operator, since ''​dayColumn''​ is a Table, not a Lookup Table — while ''​column''​ reads that same row's running total from ''​runningSums'',​ the base table the whole expression is evaluated against. Converting first is possible — ''​LookupTable dayColumn''​ — and the Lookup Table operator is cheaper per query, but the conversion walks the whole column before any query happens; for a table consulted once per pass, as here, that up-front cost can outweigh what it saves.
  
 The second loop repeats this shape for the sum of squared deviations, referencing ''​%averages[line]''​ — the already-computed per-station mean from the first loop, looked up by key — the same chaining technique already established in [[#​computing_new_columns_for_a_table|Computing new columns for a table]]. Its variables all carry a ''​dev''​ marker (''​devDayNumber'',​ ''​devRawColumnIndex'',​ ''​devDayColumn'',​ ''​devRunningSums'',​ ''​nextDevRunningSums''​) to keep them clearly distinct from the first loop'​s,​ even though each ''​For''​ loop is its own separate container. Two muxes thread through this version instead of the four the row-by-row approach needed — one ''​MuxLookupTable''​ carrying the running sums day to day, a second carrying the running squared-deviation sums — and both sit at the same level; there'​s still no inner mux within either pass, since ''​%''​ handles summing across all stations for a given day in one call rather than needing an accumulator of its own. The second loop repeats this shape for the sum of squared deviations, referencing ''​%averages[line]''​ — the already-computed per-station mean from the first loop, looked up by key — the same chaining technique already established in [[#​computing_new_columns_for_a_table|Computing new columns for a table]]. Its variables all carry a ''​dev''​ marker (''​devDayNumber'',​ ''​devRawColumnIndex'',​ ''​devDayColumn'',​ ''​devRunningSums'',​ ''​nextDevRunningSums''​) to keep them clearly distinct from the first loop'​s,​ even though each ''​For''​ loop is its own separate container. Two muxes thread through this version instead of the four the row-by-row approach needed — one ''​MuxLookupTable''​ carrying the running sums day to day, a second carrying the running squared-deviation sums — and both sit at the same level; there'​s still no inner mux within either pass, since ''​%''​ handles summing across all stations for a given day in one call rather than needing an accumulator of its own.
Line 871: Line 967:
 </​code>​ </​code>​
  
-Unlike every example in the previous section, neither of these needs a loopa mux, or the parallelism cost that comes with one — ''​GetTableFromKey''​ does the equivalent of what a whole ''​For''​ loop with ''​GetTableColumn''​ did before, in a single call. Neither ''​stationSubTable''/''​daySubTable''​ going into ''​ExtractLookupTableAttributes'',​ nor ''​attrs''​ going into the ''​tX["​NAME"​]''​ operator right after, needs an explicit ''​LookupTable''​ carrier — the first is a direct connection between two ports, and the second reads straight from the Lookup Table that operator is built for.+Unlike every example in the previous section, neither of these needs a loop or a mux — ''​GetTableFromKey''​ does the equivalent of what a whole ''​For''​ loop with ''​GetTableColumn''​ did before, in a single call. Neither ''​stationSubTable''/''​daySubTable''​ going into ''​ExtractLookupTableAttributes'',​ nor ''​attrs''​ going into the ''​tX["​NAME"​]''​ operator right after, needs an explicit ''​LookupTable''​ carrier — the first is a direct connection between two ports, and the second reads straight from the Lookup Table that operator is built for.
  
 This "no loop" advantage is specific to querying a single, known key. Computing the same statistic for **every** station, or **every** day, still needs a loop over that set of keys — though only one level of it, since each pass resolves its whole aggregate in a single ''​ExtractLookupTableAttributes''​ call, with no inner accumulator the way the wide-format loops needed one. This "no loop" advantage is specific to querying a single, known key. Computing the same statistic for **every** station, or **every** day, still needs a loop over that set of keys — though only one level of it, since each pass resolves its whole aggregate in a single ''​ExtractLookupTableAttributes''​ call, with no inner accumulator the way the wide-format loops needed one.
Line 930: Line 1026:
 </​code>​ </​code>​
  
-Both follow the same shape as the single-key examples above, just wrapped in a ''​ForEach''​ over every key and accumulated with ''​MuxLookupTable''​ — one accumulator per statistic — the same accumulator pattern used throughout this page, disqualifying these loops from parallel execution the same way.+Both follow the same shape as the single-key examples above, just wrapped in a ''​ForEach''​ over every key and accumulated with ''​MuxLookupTable''​ — one accumulator per statistic — the same accumulator pattern used throughout this page.
  
 ===== Printing a table with any number of key columns ===== ===== Printing a table with any number of key columns =====
Line 949: Line 1045:
 ]; ];
  
-result := CalculatePythonExpression ​(String ​$"(+result := CalculatePythonExpression $"(
 inputTable = dinamica.inputs['​t1'​] inputTable = dinamica.inputs['​t1'​]
 header = inputTable[0] header = inputTable[0]
 rows = inputTable[1:​] rows = inputTable[1:​]
  
-column ​name may carry a "#​type"​ suffix after its "​*"​ key marker, so +header holds plain column ​names only, so nothing needs stripping here.
-# the marker is stripped from the name part alone and the suffix put back +
-# untouched. +
-def dropKeyMarker(name):​ +
-    columnName, separator, columnType = name.partition('#'​) +
-    return columnName.rstrip('​*'​) + separator + columnType +
 # Every original column, key or not, becomes a plain data column; a single # Every original column, key or not, becomes a plain data column; a single
 # sequential integer becomes the new (and only) key. # sequential integer becomes the new (and only) key.
-newHeader = ['​Id*'​] + [dropKeyMarker(name) for name in header]+newHeader = ['​Id*'​] + list(header)
 newRows = [[i + 1] + list(row) for i, row in enumerate(rows)] newRows = [[i + 1] + list(row) for i, row in enumerate(rows)]
 newTable = [newHeader] + newRows newTable = [newHeader] + newRows
  
 dinamica.outputs['​reshapedTable'​] = dinamica.prepareTable(newTable,​ 1) dinamica.outputs['​reshapedTable'​] = dinamica.prepareTable(newTable,​ 1)
-)"{{+)" {{
     NumberTable myTable 1;     NumberTable myTable 1;
 }}; }};
Line 990: Line 1080:
  
             // Same generic Real/String printing technique as the earlier             // Same generic Real/String printing technique as the earlier
-            // example: ​attempt String first, SkipOnError ​catches the +            // example: ​try both conversions independentlyeach guarded by 
-            // failure if it'​s ​actually ​a Real, RealValue then converts it+            // its own SkipOnError, and let StringJunction pick whichever 
-            ​attempt ​:= SkipOnError .yes {{ +            // one was actually ​produced
-                ​asString ​:= String cellValue;+            ​:= SkipOnError .yes {{ 
 +                ​stringDisplay ​:= String cellValue;
             }};             }};
  
-            _ := IfThen attempt {{ +            _ := SkipOnError .yes {{
-                stringDisplay := String asString; +
-            }}; +
- +
-            _ := IfNotThen attempt ​{{+
                 asReal := RealValue cellValue;                 asReal := RealValue cellValue;
                 realDisplay := String asReal;                 realDisplay := String asReal;
Line 1019: Line 1106:
 </​code>​ </​code>​
  
-The original key columns' names lose their ''​*'' ​marker as the new header is built, since they'​re plain data now — the marker comes off the name alone, so an explicit ​''#​type'' ​suffix survives it — and the new ''​Id'' ​column carries the marker ​instead, as the only key. Everything after the Python call is the same single-key iteration and generic value-printing already established — nothing about it needs to know how many keys the original table had.+''​dinamica.inputs''​ passes column ​names alone — no ''​*'' ​key markers and no ''#​type'' ​annotations ​— so the Python side has nothing to strip, ​and no way to tell which of the original columns were keys. Losing that distinction costs nothing here, since the reshape turns every original column into a plain data column regardless. The outgoing header marks ''​Id''​ instead, ​and ''​prepareTable'''​s second argument declares that one column ​as the key. Everything after the Python call is the same single-key iteration and generic value-printing already established — nothing about it needs to know how many keys the original table had.
  
 ===== Choosing between Tables and Lookup Tables ===== ===== Choosing between Tables and Lookup Tables =====
Line 1026: Line 1113:
  
 Reach for a general Table instead when a single Real value per Real key isn't enough — when a row needs more than one associated value, or a String value, or a String key, or when rows need to be indexed by more than one key at once. A String key column costs more than the type change alone: iterating one means mapping each placeholder index back to its actual key inside the loop, so prefer a Real key column wherever the data allows. Reach for a general Table instead when a single Real value per Real key isn't enough — when a row needs more than one associated value, or a String value, or a String key, or when rows need to be indexed by more than one key at once. A String key column costs more than the type change alone: iterating one means mapping each placeholder index back to its actual key inside the loop, so prefer a Real key column wherever the data allows.
 +