edf2csv 0.5.0 → 0.5.13

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.
@@ -1 +1 @@
1
- {"version":3,"file":"plan.js","sourceRoot":"","sources":["../../src/convert/plan.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACxE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAC3C,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AACrF,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAiGpE,MAAM,SAAS,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;AAE9C,oFAAoF;AACpF,MAAM,CAAC,MAAM,qBAAqB,GAAG,SAAS,CAAC;AAE/C,MAAM,UAAU,SAAS,CAAC,KAAgB,EAAE,UAAuB,EAAE;IACnE,oFAAoF;IACpF,yEAAyE;IACzE,aAAa,CAAC,OAAO,CAAC,CAAC;IAEvB,MAAM,WAAW,GAAiB,EAAE,CAAC;IACrC,MAAM,WAAW,GAAG,gBAAgB,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAEpD,sFAAsF;IACtF,uFAAuF;IACvF,wFAAwF;IACxF,KAAK,MAAM,MAAM,IAAI,kBAAkB,CAAC,KAAK,CAAC,OAAO,EAAE,WAAW,CAAC,EAAE,CAAC;QACpE,WAAW,CAAC,IAAI,CAAC;YACf,IAAI,EAAE,iBAAiB;YACvB,QAAQ,EAAE,SAAS;YACnB,OAAO,EACL,UAAU,MAAM,CAAC,KAAK,iBAAiB,MAAM,CAAC,KAAK,mCAAmC;gBACtF,8DAA8D,WAAW,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI;YACjG,IAAI,EAAE,oFAAoF;SAC3F,CAAC,CAAC;IACL,CAAC;IAED,MAAM,KAAK,GAAG,YAAY,CAAC;QACzB,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,SAAS,EAAE,OAAO,CAAC,SAAS;QAC5B,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,GAAG,EAAE,OAAO,CAAC,GAAG;QAChB,OAAO,EAAE,OAAO,CAAC,OAAO;QACxB,cAAc,EAAE,KAAK,CAAC,cAAc;QACpC,WAAW,EAAE,KAAK,CAAC,WAAW;QAC9B,YAAY,EAAE,KAAK,CAAC,YAAY;KACjC,CAAC,CAAC;IAEH,MAAM,YAAY,GAAG,OAAO,CAAC,eAAe,KAAK,IAAI,CAAC;IAEtD,IAAI,MAAM,GAAgB,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC;IAExE;;;;;;;;;MASE;IACF,IAAI,OAAO,CAAC,QAAQ,IAAI,OAAO,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACpD,MAAM,SAAS,GAAG,cAAc,CAAC,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC;QAClE,IAAI,YAAY;YAAE,MAAM,GAAG,SAAS,CAAC,OAAO,CAAC;QAC7C,KAAK,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,SAAS,CAAC,SAAS,EAAE,CAAC;YACpD,WAAW,CAAC,IAAI,CAAC;gBACf,IAAI,EAAE,iBAAiB;gBACvB,QAAQ,EAAE,SAAS;gBACnB,OAAO,EACL,IAAI,IAAI,aAAa,OAAO,CAAC,MAAM,uBAAuB;oBAC1D,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,+BAA+B;gBAC7E,IAAI,EAAE,oBAAoB,OAAO,CAAC,CAAC,CAAC,EAAE,KAAK,IAAI,CAAC,qBAAqB;aACtE,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,MAAM,CAAC;IACxC,MAAM,MAAM,GAAG,YAAY;QACzB,CAAC,CAAC,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,CAAC,QAAQ,EAAE,OAAO,CAAC,IAAI,KAAK,IAAI,EAAE,MAAM,CAAC;QACnF,CAAC,CAAC,EAAE,CAAC;IACP,MAAM,QAAQ,GAAG,cAAc,CAC7B,MAAM,EACN,KAAK,EACL,KAAK,CAAC,cAAc,EACpB,KAAK,CAAC,YAAY,EAClB,OAAO,CAAC,GAAG,KAAK,IAAI,EACpB,MAAM,CACP,CAAC;IAEF;;;;;;;;;;;;MAYE;IACF,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtB,WAAW,CAAC,IAAI,CAAC;YACf,IAAI,EAAE,sBAAsB;YAC5B,QAAQ,EAAE,SAAS;YACnB,OAAO,EACL,gBAAgB,MAAM,CAAC,MAAM,4BAA4B;gBACzD,IAAI,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,IAAI;YAC9E,IAAI,EACF,MAAM,KAAK,MAAM;gBACf,CAAC,CAAC,mFAAmF;gBACrF,CAAC,CAAC,mEAAmE;SAC1E,CAAC,CAAC;IACL,CAAC;IAED;;;;;;;;;MASE;IACF,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QACjD,IAAI,IAAI,GAAG,CAAC,IAAI,IAAI,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,YAAY,EAAE,CAAC;YACjD,WAAW,CAAC,IAAI,CAAC;gBACf,IAAI,EAAE,iBAAiB;gBACvB,QAAQ,EAAE,SAAS;gBACnB,OAAO,EACL,eAAe,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,6CAA6C;oBAClF,uCAAuC,KAAK,CAAC,QAAQ,+BAA+B;gBACtF,IAAI,EACF,mFAAmF;oBACnF,4DAA4D;aAC/D,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED;;;;;;;;MAQE;IACF,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,MAAM,KAAK,GAAG,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,kBAAkB,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC;QACrF,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,SAAS;QACjC,WAAW,CAAC,IAAI,CAAC;YACf,IAAI,EAAE,kBAAkB;YACxB,QAAQ,EAAE,SAAS;YACnB,OAAO,EACL,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,WAAW;gBACzF,YAAY,KAAK,CAAC,CAAC,CAAC,EAAE,QAAQ,qDAAqD;gBACnF,sCAAsC,KAAK,CAAC,QAAQ,GAAG;YACzD,IAAI,EACF,kFAAkF;gBAClF,iEAAiE;SACpE,CAAC,CAAC;IACL,CAAC;IAED,IAAI,QAAQ,CAAC,uBAAuB,EAAE,CAAC;QACrC,WAAW,CAAC,IAAI,CAAC;YACf,IAAI,EAAE,cAAc;YACpB,QAAQ,EAAE,SAAS;YACnB,OAAO,EACL,gDAAgD,qBAAqB,CAAC,cAAc,CAAC,OAAO,CAAC,GAAG;gBAChG,qDAAqD;YACvD,IAAI,EAAE,qFAAqF;SAC5F,CAAC,CAAC;IACL,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,KAAK,IAAI,EAAE,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,QAAQ,EAAE,CAAC;AAClH,CAAC;AAED;;;;;;GAMG;AACH,SAAS,WAAW,CAClB,OAA6B,EAC7B,WAAgC,EAChC,cAAkC,EAClC,IAAa,EACb,MAAuB;IAEvB,MAAM,MAAM,GAAG,IAAI,GAAG,EAAuB,CAAC;IAC9C,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,wEAAwE;QACxE,2EAA2E;QAC3E,IAAI,MAAM,CAAC,gBAAgB,KAAK,CAAC;YAAE,SAAS;QAC5C,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;QAC/C,IAAI,MAAM;YAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;;YAC3B,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,YAAY,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;IACjD,CAAC;IAED,MAAM,KAAK,GAAG,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IACvD,oFAAoF;IACpF,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,MAAM,KAAK,MAAM,CAAC;IAEvD;;;;;;;;;;;;;;;;;MAiBE;IACF,MAAM,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC;IACzC,MAAM,KAAK,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,IAAI,CAAC,CAAC;IAC9E,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,MAAM,UAAU,GAAG,CAAC,KAAa,EAAU,EAAE;QAC3C,MAAM,IAAI,GAAG,WAAW,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC;QACvC,IAAI,IAAI,GAAG,GAAG,IAAI,GAAG,MAAM,EAAE,CAAC;QAC9B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE;YAAE,IAAI,GAAG,GAAG,IAAI,IAAI,CAAC,GAAG,MAAM,EAAE,CAAC;QACpE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACf,OAAO,IAAI,CAAC;IACd,CAAC,CAAC;IAEF,OAAO,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE;QAC/B,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;QACvC,MAAM,KAAK,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;QACzB,OAAO;YACL,IAAI;YACJ,gBAAgB,EAAE,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC;YACpD,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC,UAAU,MAAM,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC;YACzD;;;;cAIE;YACF,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,IAAI,CAAC;YAC3F,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;gBACjC,MAAM;gBACN,MAAM,EAAE,WAAW,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,UAAU,MAAM,CAAC,KAAK,EAAE;gBACjE,QAAQ,EAAE,cAAc,IAAI,iBAAiB,CAAC,MAAM,CAAC;aACtD,CAAC,CAAC;SACJ,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,sBAAsB,CAAC,WAAkC;IACvE,OAAO,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,sBAAsB,CAAC,CAAC;AACtE,CAAC;AAED,gEAAgE;AAChE,MAAM,UAAU,QAAQ,CAAC,IAAY;IACnC,OAAO,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,IAAI,CAAC;AACnD,CAAC;AAED,mFAAmF;AACnF,SAAS,OAAO,CAAC,SAAiB,EAAE,QAAgB,EAAE,MAAM,GAAG,KAAK;IAClE,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IACjC,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC5B,MAAM,QAAQ,GAAG,QAAQ,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;IAEjD;;;;;;;;;MASE;IACF,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,GAAG,CAAC,GAAG,QAAQ,CAAC;IACvD,IAAI,IAAI,GAAG,IAAI;QAAE,OAAO,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC;IAC5E,OAAO,IAAI,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,QAAQ,CAAC;AAC9D,CAAC;AAED,SAAS,cAAc,CACrB,MAA4B,EAC5B,KAAoB,EACpB,cAAsB,EACtB,YAA6C,EAC7C,GAAY,EACZ,MAAuB;IAEvB,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,IAAI,OAAO,GAAG,KAAK,CAAC;IACpB,oFAAoF;IACpF,sFAAsF;IACtF,IAAI,QAAQ,GAAG,CAAC,CAAC;IAEjB,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,IAAI,SAAS,GAAG,CAAC,CAAC;QAClB,KAAK,IAAI,MAAM,GAAG,KAAK,CAAC,WAAW,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,CAAC;YACxE,MAAM,WAAW,GAAG,YAAY;gBAC9B,CAAC,CAAC,CAAC,YAAY,CAAC,MAAM,CAAC,IAAI,MAAM,GAAG,cAAc,CAAC;gBACnD,CAAC,CAAC,MAAM,GAAG,cAAc,CAAC;YAC5B,SAAS,IAAI,mBAAmB,CAAC;gBAC/B,WAAW;gBACX,IAAI,EAAE,KAAK,CAAC,IAAI;gBAChB,gBAAgB,EAAE,KAAK,CAAC,gBAAgB;gBACxC,YAAY,EAAE,KAAK,CAAC,YAAY;gBAChC,UAAU,EAAE,KAAK,CAAC,UAAU;aAC7B,CAAC,CAAC;QACL,CAAC;QACD,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;YACtB,kEAAkE;YAClE,MAAM,UAAU,GAAG,SAAS,GAAG,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC;YACrD,IAAI,IAAI,UAAU,CAAC;YACnB,QAAQ,IAAI,UAAU,CAAC;YACvB;;;;cAIE;YACF,MAAM,SAAS,GAAG,OAAO,CAAC,KAAK,CAAC,UAAU,EAAE,KAAK,CAAC,YAAY,CAAC,CAAC;YAChE,KAAK,MAAM,OAAO,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;gBACrC,MAAM,UAAU,GAAG,OAAO,CACxB,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,EACpF,OAAO,CAAC,QAAQ,EAChB,OAAO,CAAC,MAAM,CAAC,WAAW,GAAG,CAAC,IAAI,OAAO,CAAC,MAAM,CAAC,WAAW,GAAG,CAAC,CACjE,CAAC;gBACF,MAAM,SAAS,GAAG,MAAM,CAAC,UAAU,CAAC,cAAc,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;gBACpE,8BAA8B;gBAC9B,KAAK,IAAI,SAAS,GAAG,CAAC,SAAS,GAAG,SAAS,GAAG,UAAU,GAAG,CAAC,CAAC,CAAC;YAChE,CAAC;YACD,SAAS;QACX,CAAC;QAED,IAAI,IAAI,SAAS,CAAC;QAClB,IAAI,SAAS,GAAG,CAAC,GAAG,qBAAqB;YAAE,OAAO,GAAG,IAAI,CAAC;QAE1D;;;;;;;;;;;;;;;;UAgBE;QACF,MAAM,SAAS,GAAG,OAAO,CAAC,KAAK,CAAC,UAAU,EAAE,KAAK,CAAC,YAAY,CAAC,CAAC;QAChE,MAAM,SAAS,GAAG,KAAK,CAAC,QAAQ,CAAC,MAAM,CACrC,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,CACT,GAAG;YACH,OAAO,CACL,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,EACxE,CAAC,CAAC,QAAQ,EACV,CAAC,CAAC,MAAM,CAAC,WAAW,GAAG,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,WAAW,GAAG,CAAC,CACrD,EACH,CAAC,CACF,CAAC;QACF,2CAA2C;QAC3C,KAAK,IAAI,SAAS,GAAG,CAAC,SAAS,GAAG,SAAS,GAAG,KAAK,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QACzE;;;;;;;;;;;UAWE;QACF,KAAK,IAAI,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,GAAG,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;QAC3F,mFAAmF;QACnF,sFAAsF;QACtF,IAAI,GAAG;YAAE,KAAK,IAAI,SAAS,CAAC;IAC9B,CAAC;IAED,IAAI,MAAM,KAAK,MAAM,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC3C,IAAI,QAAQ,GAAG,CAAC,GAAG,qBAAqB;YAAE,OAAO,GAAG,IAAI,CAAC;QACzD,KAAK,IAAI,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;QACvE,IAAI,GAAG;YAAE,KAAK,IAAI,SAAS,CAAC;IAC9B,CAAC;IAED,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,uBAAuB,EAAE,OAAO,EAAE,CAAC;AAC3D,CAAC","sourcesContent":["/**\n * Turning a request into a concrete conversion plan.\n *\n * The plan is where the tool's central promise is enforced: channels recorded at\n * different sampling rates are never merged into one table. A single wide CSV can\n * only hold mixed rates by inventing samples for the slow channels — MNE, for\n * instance, expands three genuine 1 Hz temperature readings into 768 interpolated\n * values without warning. Instead each distinct rate gets its own file, so every\n * number in every output file is a number that was actually recorded.\n */\n\nimport type { Diagnostic } from '../edf/errors.js';\nimport type { EdfSignal } from '../edf/header.js';\nimport { formatRate, formatRates } from '../edf/header.js';\nimport { decimalsAreClamped, decimalsForSignal } from '../edf/scale.js';\nimport { UTF8_BOM, csvRow, escapeCsvField } from '../format/csv.js';\nimport { listed } from '../format/list.js';\nimport { timeDecimals } from '../format/number.js';\nimport { buildColumnNames, renamedByCollision, selectChannels } from './channels.js';\nimport { assertOptions } from './options.js';\nimport { countSamplesInRange, resolveRange } from './time-range.js';\nimport type { ResolvedRange } from './time-range.js';\n\nexport interface PlannedChannel {\n signal: EdfSignal;\n column: string;\n decimals: number;\n}\n\nexport interface RateGroup {\n /** Sampling rate in Hz shared by every channel in this group. */\n rate: number;\n samplesPerRecord: number;\n fileName: string;\n timeDecimals: number;\n channels: PlannedChannel[];\n}\n\nexport interface PlanInput {\n signals: readonly EdfSignal[];\n recordDuration: number;\n recordCount: number;\n hasAnnotationChannel: boolean;\n /**\n * True start time of each data record, supplied for discontinuous files. The\n * requested time window is resolved against these rather than against\n * `recordCount * recordDuration`, which for a file with gaps is the amount of\n * data rather than the span of time it covers.\n */\n recordStarts?: Float64Array | null | undefined;\n}\n\nexport interface PlanOptions {\n channels?: readonly string[] | undefined;\n start?: number | undefined;\n /** The `--start` value exactly as typed, for error messages. */\n startText?: string | undefined;\n duration?: number | undefined;\n end?: number | undefined;\n /** The `--end` value exactly as typed, for error messages. */\n endText?: string | undefined;\n annotationsOnly?: boolean | undefined;\n /** Force a fixed number of decimals instead of deriving it per channel. */\n decimals?: number | undefined;\n /** Compress each CSV with gzip, giving every one of them a `.gz` name. */\n gzip?: boolean | undefined;\n /** Start each CSV with a UTF-8 byte order mark, so Excel reads it as UTF-8. */\n bom?: boolean | undefined;\n /**\n * How the samples are arranged in the CSV.\n *\n * `'wide'`, the default, gives one column per channel and one file per sampling rate.\n * `'long'` gives one file, three columns — `time_s`, `channel`, `value` — and one row per\n * sample. See ConversionPlan.layout for why that is the only way to put channels recorded\n * at different rates in one table without inventing samples.\n */\n layout?: 'wide' | 'long' | undefined;\n}\n\nexport interface ConversionPlan {\n groups: RateGroup[];\n /**\n * How the samples are arranged. `'wide'` is a column per channel and a file per rate;\n * `'long'` is `time_s,channel,value`, one row per sample, all rates in one file.\n *\n * The wide layout has to split a mixed-rate recording across files: a 100 Hz channel and\n * a 1 Hz channel share no rows, and putting them in one wide table means either 99 empty\n * cells out of every hundred or inventing the samples that would fill them. In the long\n * layout each sample carries its own time, so nothing has to line up and nothing is\n * invented — which also makes it the one layout `--stdout` can stream for such a file.\n */\n layout: 'wide' | 'long';\n\n /**\n * Whether the CSVs will be compressed.\n *\n * Recorded rather than inferred from the group file names. Under `--annotations-only`\n * there are no groups to read it off, and `--info` named `annotations.csv` for a run that\n * wrote `annotations.csv.gz`.\n */\n gzip: boolean;\n range: ResolvedRange;\n columnNames: Map<number, string>;\n writeSignals: boolean;\n diagnostics: Diagnostic[];\n estimate: OutputEstimate;\n}\n\nexport interface OutputEstimate {\n /** Total data rows across every signal file. */\n rows: number;\n /** Approximate size of the signal CSVs on disk. */\n bytes: number;\n /** True when any single file would exceed Excel's row limit. */\n exceedsSpreadsheetLimit: boolean;\n}\n\nconst BOM_BYTES = Buffer.byteLength(UTF8_BOM);\n\n/** Excel and most spreadsheet tools stop at 1,048,576 rows including the header. */\nexport const SPREADSHEET_ROW_LIMIT = 1_048_576;\n\nexport function buildPlan(input: PlanInput, options: PlanOptions = {}): ConversionPlan {\n // First, and before a directory is created or a stream opened, so a rejected option\n // leaves nothing behind. See assertOptions for what used to get through.\n assertOptions(options);\n\n const diagnostics: Diagnostic[] = [];\n const columnNames = buildColumnNames(input.signals);\n\n // A channel whose own label was taken by another channel's disambiguating suffix. The\n // duplicate-label warning is about the labels that collided; this is about the channel\n // that lost its name to them, which is the one whose column no longer matches the file.\n for (const signal of renamedByCollision(input.signals, columnNames)) {\n diagnostics.push({\n code: 'DUPLICATE_LABEL',\n severity: 'warning',\n message:\n `Signal ${signal.index} is labelled \"${signal.label}\", which is also the column name ` +\n `another channel's \"_ch\" suffix produces, so its column is \"${columnNames.get(signal.index)}\".`,\n hint: 'Column names are unique; look this channel up in channels.csv by its signal_index.',\n });\n }\n\n const range = resolveRange({\n start: options.start,\n startText: options.startText,\n duration: options.duration,\n end: options.end,\n endText: options.endText,\n recordDuration: input.recordDuration,\n recordCount: input.recordCount,\n recordStarts: input.recordStarts,\n });\n\n const writeSignals = options.annotationsOnly !== true;\n\n let chosen: EdfSignal[] = input.signals.filter((s) => !s.isAnnotations);\n\n /*\n Channel names are checked even under --annotations-only, where the selection is not\n otherwise used.\n\n Skipping the check meant `--channels TYPO --annotations-only` exited 0 in silence while\n the same typo without the flag was a usage error, and `--channels \"\"` stayed an error\n in both — so a mistyped name was the one form of bad input the tool accepted quietly.\n Everywhere else a term matching nothing is reported rather than ignored; a flag that\n happens not to apply is a poor reason to make an exception.\n */\n if (options.channels && options.channels.length > 0) {\n const selection = selectChannels(input.signals, options.channels);\n if (writeSignals) chosen = selection.signals;\n for (const { term, matched } of selection.ambiguous) {\n diagnostics.push({\n code: 'DUPLICATE_LABEL',\n severity: 'warning',\n message:\n `\"${term}\" matches ${matched.length} channels (positions ` +\n `${listed(matched.map((s) => `#${s.index}`))}); all of them were selected.`,\n hint: `Use --channels \"#${matched[0]?.index ?? 0}\" to pick just one.`,\n });\n }\n }\n\n const layout = options.layout ?? 'wide';\n const groups = writeSignals\n ? groupByRate(chosen, columnNames, options.decimals, options.gzip === true, layout)\n : [];\n const estimate = estimateOutput(\n groups,\n range,\n input.recordDuration,\n input.recordStarts,\n options.bom === true,\n layout,\n );\n\n /*\n The mixed-rate warning describes what this conversion does, not what the file holds.\n\n The header parser raises its own, which is right for `parseHeader` — but it sees every\n channel and knows nothing about `--channels`. Converting one channel out of a three-rate\n recording therefore announced \"3 different sampling rates ... written to one file per\n rate\" over a run that wrote one file, in the same output where `--info` had already\n marked the other two \"(not selected)\". Selecting two of the three was wrong the other\n way: still \"3\".\n\n Callers combining these with a file's own diagnostics drop that copy in favour of this\n one; see `withoutFileRateWarning`.\n */\n if (groups.length > 1) {\n diagnostics.push({\n code: 'MIXED_SAMPLING_RATES',\n severity: 'warning',\n message:\n `Channels use ${groups.length} different sampling rates ` +\n `(${listed(formatRates(groups.map((g) => g.rate)).map((r) => `${r} Hz`))}).`,\n hint:\n layout === 'long'\n ? 'They share one table, each row carrying its own time, so no channel is resampled.'\n : 'They are written to one file per rate so no channel is resampled.',\n });\n }\n\n /*\n A time column that cannot tell two samples apart.\n\n Sample times are written to at most nine decimal places, which separates everything up to\n a gigahertz. Below that the column repeats: a recording of 1 ns records holding ten\n samples each writes twenty rows carrying three distinct times, so joining or plotting on\n `time_s` silently collapses them. Nothing is lost from the file — every sample is there,\n in order — but the column stops being an identifier, and that is worth saying rather than\n leaving to be discovered.\n */\n for (const group of groups) {\n const step = group.rate > 0 ? 1 / group.rate : 0;\n if (step > 0 && step < 10 ** -group.timeDecimals) {\n diagnostics.push({\n code: 'TIME_RESOLUTION',\n severity: 'warning',\n message:\n `Channels at ${formatRate(group.rate)} Hz sample faster than the time column can ` +\n `distinguish, so consecutive rows in ${group.fileName} carry the same time_s value.`,\n hint:\n 'Every sample is written, in order. Use the row number rather than time_s to tell ' +\n 'them apart, or convert one rate at a time with --channels.',\n });\n }\n }\n\n /*\n The same failure as TIME_RESOLUTION, one column over.\n\n A channel whose quantization step is below 1e-98 needs more decimals than `toFixed` can\n print, so consecutive digital codes round to the same text and the arithmetic the FAQ\n gives for recovering them stops working. That used to happen at 1e-20 and silently — see\n MAX_DERIVED_DECIMALS. It is rare now, but \"rare\" is the reason to say so rather than the\n reason not to.\n */\n for (const group of groups) {\n const short = group.channels.filter((c) => decimalsAreClamped(c.signal, c.decimals));\n if (short.length === 0) continue;\n diagnostics.push({\n code: 'VALUE_RESOLUTION',\n severity: 'warning',\n message:\n `${listed(short.map((c) => c.column))} ${short.length === 1 ? 'steps' : 'step'} by less ` +\n `than the ${short[0]?.decimals} decimals written can express, so some consecutive ` +\n `samples round to the same value in ${group.fileName}.`,\n hint:\n 'Every sample is written, in order, and the physical values are computed at full ' +\n 'precision either way. What is lost is only in the printed text.',\n });\n }\n\n if (estimate.exceedsSpreadsheetLimit) {\n diagnostics.push({\n code: 'LARGE_OUTPUT',\n severity: 'warning',\n message:\n `At least one output file will have more than ${SPREADSHEET_ROW_LIMIT.toLocaleString('en-US')} ` +\n `rows, which is more than Excel or Numbers can open.`,\n hint: 'Use --start and --duration to convert a section, or read the file with pandas or R.',\n });\n }\n\n return { groups, layout, gzip: options.gzip === true, range, columnNames, writeSignals, diagnostics, estimate };\n}\n\n/**\n * Partition channels by sampling rate, largest first.\n *\n * The common case — every channel at one rate — collapses to a single group and a\n * single `signals.csv`, so the honest behaviour costs nothing when there is nothing\n * to be honest about.\n */\nfunction groupByRate(\n signals: readonly EdfSignal[],\n columnNames: Map<number, string>,\n forcedDecimals: number | undefined,\n gzip: boolean,\n layout: 'wide' | 'long',\n): RateGroup[] {\n const byRate = new Map<number, EdfSignal[]>();\n for (const signal of signals) {\n // A channel with no samples has no sampling rate to group by, and would\n // otherwise produce an empty \"0hz\" file. The header parser already warned.\n if (signal.samplesPerRecord === 0) continue;\n const bucket = byRate.get(signal.samplingRate);\n if (bucket) bucket.push(signal);\n else byRate.set(signal.samplingRate, [signal]);\n }\n\n const rates = [...byRate.keys()].sort((a, b) => b - a);\n // The long layout writes one table whatever the rates are, so every group names it.\n const single = rates.length === 1 || layout === 'long';\n\n /*\n Two distinct rates can produce the same slug, because the slug rounds to six decimal\n places. Rates come from samplesPerRecord / recordDuration and every channel shares the\n record duration, so the closest two rates can be is 1 / recordDuration — which drops\n below 1e-6 once a record is longer than about eleven days. Absurd, but the header\n permits it, and the failure was silent and destructive: both groups opened a write\n stream on the same path, so the file ended up holding interleaved rows from both\n channels under a header naming only one of them.\n\n Distinct rates therefore get distinct files, always. The suffix is only ever reached by\n a collision, so ordinary recordings keep the names they have always had.\n\n Naming from the whole set of rates at once removes most of those collisions before the\n suffix has to. Rounding each rate on its own gave 1e-6 Hz and 1.25e-6 Hz the same slug,\n and the numbering below then produced signals_0_000001hz.csv and signals_0_000001hz_2.csv\n — two files that no longer overwrite each other, but of which only one is named for the\n rate it holds. The suffix stays as the backstop for anything this still cannot separate.\n */\n const suffix = gzip ? '.csv.gz' : '.csv';\n const slugs = formatRates(rates).map((text) => `${text.replace('.', '_')}hz`);\n const used = new Set<string>();\n const uniqueName = (index: number): string => {\n const base = `signals_${slugs[index]}`;\n let name = `${base}${suffix}`;\n for (let n = 2; used.has(name); n++) name = `${base}_${n}${suffix}`;\n used.add(name);\n return name;\n };\n\n return rates.map((rate, index) => {\n const members = byRate.get(rate) ?? [];\n const first = members[0];\n return {\n rate,\n samplesPerRecord: first ? first.samplesPerRecord : 0,\n fileName: single ? `signals${suffix}` : uniqueName(index),\n /*\n In the long layout every rate shares a `time_s` column, so they share its precision:\n the finest any of them needs. Writing 100 Hz at three places and 256 Hz at eight in\n the same column would make the column's meaning depend on the row.\n */\n timeDecimals: layout === 'long' ? Math.max(...rates.map(timeDecimals)) : timeDecimals(rate),\n channels: members.map((signal) => ({\n signal,\n column: columnNames.get(signal.index) ?? `signal_${signal.index}`,\n decimals: forcedDecimals ?? decimalsForSignal(signal),\n })),\n };\n });\n}\n\n/**\n * A file's diagnostics with the header's mixed-rate warning removed.\n *\n * `buildPlan` raises that warning for the channels actually being converted, so keeping both\n * would either duplicate it or contradict it. The header parser's copy stays where it is, for\n * callers reading a header without planning a conversion.\n */\nexport function withoutFileRateWarning(diagnostics: readonly Diagnostic[]): Diagnostic[] {\n return diagnostics.filter((d) => d.code !== 'MIXED_SAMPLING_RATES');\n}\n\n/** `256hz`, `12_5hz` — safe in a filename on every platform. */\nexport function rateSlug(rate: number): string {\n return `${formatRate(rate).replace('.', '_')}hz`;\n}\n\n/** Characters a fixed-decimal number of this magnitude occupies, sign included. */\nfunction widthOf(magnitude: number, decimals: number, signed = false): number {\n const size = Math.abs(magnitude);\n const sign = signed ? 1 : 0;\n const fraction = decimals > 0 ? 1 + decimals : 0;\n\n /*\n Cells are written with toFixed, which rounds. Taking the integer digits from the floor of\n the bound therefore under-counted whenever rounding carried into a new digit: a channel\n bounded at 9999.999 and written to zero decimals produces \"10000\", five characters where\n the floor of 9999.999 suggests four. Every cell on such a channel was a byte short, and\n `--info` reported 127 KB for a file that came out 131 KB.\n\n Measuring the bound as rendered removes that. toFixed switches to exponential notation\n past 1e21, so the arithmetic form still covers magnitudes beyond it.\n */\n if (!Number.isFinite(size)) return sign + 1 + fraction;\n if (size < 1e21) return sign + size.toFixed(Math.min(decimals, 100)).length;\n return sign + (Math.floor(Math.log10(size)) + 1) + fraction;\n}\n\nfunction estimateOutput(\n groups: readonly RateGroup[],\n range: ResolvedRange,\n recordDuration: number,\n recordStarts: Float64Array | null | undefined,\n bom: boolean,\n layout: 'wide' | 'long',\n): OutputEstimate {\n let rows = 0;\n let bytes = 0;\n let exceeds = false;\n // One table in the long layout, so the row limit applies to the sum rather than the\n // largest group, and the header and mark are counted once rather than once per group.\n let longRows = 0;\n\n for (const group of groups) {\n let groupRows = 0;\n for (let record = range.startRecord; record < range.endRecord; record++) {\n const recordStart = recordStarts\n ? (recordStarts[record] ?? record * recordDuration)\n : record * recordDuration;\n groupRows += countSamplesInRange({\n recordStart,\n rate: group.rate,\n samplesPerRecord: group.samplesPerRecord,\n startSeconds: range.startSeconds,\n endSeconds: range.endSeconds,\n });\n }\n if (layout === 'long') {\n // A row per sample per channel rather than a row per sample time.\n const groupCells = groupRows * group.channels.length;\n rows += groupCells;\n longRows += groupCells;\n /*\n `time_s,channel,value`: the time, the channel name as it will be escaped into the\n cell, and the widest the value can print. Same over-counting rule as the wide\n layout — the declared physical range bounds a cell, and most samples sit under it.\n */\n const timeWidth = widthOf(range.endSeconds, group.timeDecimals);\n for (const channel of group.channels) {\n const valueWidth = widthOf(\n Math.max(Math.abs(channel.signal.physicalMin), Math.abs(channel.signal.physicalMax)),\n channel.decimals,\n channel.signal.physicalMin < 0 || channel.signal.physicalMax < 0,\n );\n const nameWidth = Buffer.byteLength(escapeCsvField(channel.column));\n // Two commas and the newline.\n bytes += groupRows * (timeWidth + nameWidth + valueWidth + 3);\n }\n continue;\n }\n\n rows += groupRows;\n if (groupRows + 1 > SPREADSHEET_ROW_LIMIT) exceeds = true;\n\n /*\n Width per cell, from the channel's own calibration rather than a flat allowance.\n\n The old `decimals + 6` budgeted six characters for the sign, integer part and decimal\n point on every channel, whatever it actually held. That over-counted a millivolt\n channel spanning ±5 by four characters a cell and ran 30-55% high across the fixture\n set — on a number people use to decide whether a conversion is worth starting.\n\n The channel's declared physical range is what bounds a cell, so that bound is what is\n used. Most samples sit below it, so this still reads high, which is the direction a\n size estimate should err in.\n\n One case is outside the bound rather than under it: nothing obliges a recording to keep\n its samples inside the digital range it declares, and one that does not maps outside the\n physical range too. Such a file can convert larger than the estimate. Clamping the data\n to make the estimate true is not a trade worth making — the samples are what they are.\n */\n const timeWidth = widthOf(range.endSeconds, group.timeDecimals);\n const cellWidth = group.channels.reduce(\n (sum, c) =>\n sum +\n widthOf(\n Math.max(Math.abs(c.signal.physicalMin), Math.abs(c.signal.physicalMax)),\n c.decimals,\n c.signal.physicalMin < 0 || c.signal.physicalMax < 0,\n ),\n 0,\n );\n // One comma per channel, plus the newline.\n bytes += groupRows * (timeWidth + cellWidth + group.channels.length + 1);\n /*\n The header row, measured as it will be written rather than as the labels are stored.\n\n A column name is quoted when it contains a comma, a quote, a newline or a leading or\n trailing space, and every quote inside it is doubled. Counting the raw label under-counted\n that row: three channels labelled `a,b,c,d,e`, `x\"y` and `plain` write a 32-byte header\n and were budgeted 27. EDF labels are free text, so commas in them are ordinary — a montage\n written as `EEG Fpz-Cz, ref` is exactly the kind of thing this is for.\n\n csvRow is the function that writes it, so it is the function that measures it. Nothing\n else is in a position to stay correct when the quoting rules change.\n */\n bytes += Buffer.byteLength(csvRow(['time_s', ...group.channels.map((c) => c.column)])) + 1;\n // Three bytes per file under --bom. Small, but the estimate promises never to read\n // under what gets written, and a one-row conversion is small enough for it to matter.\n if (bom) bytes += BOM_BYTES;\n }\n\n if (layout === 'long' && groups.length > 0) {\n if (longRows + 1 > SPREADSHEET_ROW_LIMIT) exceeds = true;\n bytes += Buffer.byteLength(csvRow(['time_s', 'channel', 'value'])) + 1;\n if (bom) bytes += BOM_BYTES;\n }\n\n return { rows, bytes, exceedsSpreadsheetLimit: exceeds };\n}\n"]}
1
+ {"version":3,"file":"plan.js","sourceRoot":"","sources":["../../src/convert/plan.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Turning a request into a concrete conversion plan.\n *\n * The plan is where the tool's central promise is enforced: channels recorded at\n * different sampling rates are never merged into one table. A single wide CSV can\n * only hold mixed rates by inventing samples for the slow channels — MNE, for\n * instance, expands three genuine 1 Hz temperature readings into 768 interpolated\n * values without warning. Instead each distinct rate gets its own file, so every\n * number in every output file is a number that was actually recorded.\n */\n\nimport type { Diagnostic } from '../edf/errors.js';\nimport type { EdfSignal } from '../edf/header.js';\nimport { formatRate, formatRates } from '../edf/header.js';\nimport { decimalsAreClamped, decimalsForSignal } from '../edf/scale.js';\nimport { UTF8_BOM, csvRow, escapeCsvField } from '../format/csv.js';\nimport { listed } from '../format/list.js';\nimport { timeDecimals } from '../format/number.js';\nimport { buildColumnNames, renamedByCollision, selectChannels } from './channels.js';\nimport { assertOptions } from './options.js';\nimport { countSamplesInRange, resolveRange } from './time-range.js';\nimport type { ResolvedRange } from './time-range.js';\n\nexport interface PlannedChannel {\n signal: EdfSignal;\n column: string;\n decimals: number;\n}\n\nexport interface RateGroup {\n /** Sampling rate in Hz shared by every channel in this group. */\n rate: number;\n samplesPerRecord: number;\n fileName: string;\n timeDecimals: number;\n channels: PlannedChannel[];\n}\n\nexport interface PlanInput {\n signals: readonly EdfSignal[];\n recordDuration: number;\n recordCount: number;\n hasAnnotationChannel: boolean;\n /**\n * True start time of each data record, supplied for discontinuous files. The\n * requested time window is resolved against these rather than against\n * `recordCount * recordDuration`, which for a file with gaps is the amount of\n * data rather than the span of time it covers.\n */\n recordStarts?: Float64Array | null | undefined;\n}\n\nexport interface PlanOptions {\n channels?: readonly string[] | undefined;\n start?: number | undefined;\n /** The `--start` value exactly as typed, for error messages. */\n startText?: string | undefined;\n duration?: number | undefined;\n end?: number | undefined;\n /** The `--end` value exactly as typed, for error messages. */\n endText?: string | undefined;\n annotationsOnly?: boolean | undefined;\n /** Force a fixed number of decimals instead of deriving it per channel. */\n decimals?: number | undefined;\n /** Compress each CSV with gzip, giving every one of them a `.gz` name. */\n gzip?: boolean | undefined;\n /** Start each CSV with a UTF-8 byte order mark, so Excel reads it as UTF-8. */\n bom?: boolean | undefined;\n /**\n * How the samples are arranged in the CSV.\n *\n * `'wide'`, the default, gives one column per channel and one file per sampling rate.\n * `'long'` gives one file, three columns — `time_s`, `channel`, `value` — and one row per\n * sample. See ConversionPlan.layout for why that is the only way to put channels recorded\n * at different rates in one table without inventing samples.\n */\n layout?: 'wide' | 'long' | undefined;\n}\n\nexport interface ConversionPlan {\n groups: RateGroup[];\n /**\n * How the samples are arranged. `'wide'` is a column per channel and a file per rate;\n * `'long'` is `time_s,channel,value`, one row per sample, all rates in one file.\n *\n * The wide layout has to split a mixed-rate recording across files: a 100 Hz channel and\n * a 1 Hz channel share no rows, and putting them in one wide table means either 99 empty\n * cells out of every hundred or inventing the samples that would fill them. In the long\n * layout each sample carries its own time, so nothing has to line up and nothing is\n * invented — which also makes it the one layout `--stdout` can stream for such a file.\n */\n layout: 'wide' | 'long';\n\n /**\n * Whether the CSVs will be compressed.\n *\n * Recorded rather than inferred from the group file names. Under `--annotations-only`\n * there are no groups to read it off, and `--info` named `annotations.csv` for a run that\n * wrote `annotations.csv.gz`.\n */\n gzip: boolean;\n range: ResolvedRange;\n columnNames: Map<number, string>;\n writeSignals: boolean;\n diagnostics: Diagnostic[];\n estimate: OutputEstimate;\n}\n\nexport interface OutputEstimate {\n /** Total data rows across every signal file. */\n rows: number;\n /** Approximate size of the signal CSVs on disk. */\n bytes: number;\n /** True when any single file would exceed Excel's row limit. */\n exceedsSpreadsheetLimit: boolean;\n}\n\nconst BOM_BYTES = Buffer.byteLength(UTF8_BOM);\n\n/** Excel and most spreadsheet tools stop at 1,048,576 rows including the header. */\nexport const SPREADSHEET_ROW_LIMIT = 1_048_576;\n\nexport function buildPlan(input: PlanInput, options: PlanOptions = {}): ConversionPlan {\n // First, and before a directory is created or a stream opened, so a rejected option\n // leaves nothing behind. See assertOptions for what used to get through.\n assertOptions(options);\n\n const diagnostics: Diagnostic[] = [];\n const columnNames = buildColumnNames(input.signals);\n\n // A channel whose own label was taken by another channel's disambiguating suffix. The\n // duplicate-label warning is about the labels that collided; this is about the channel\n // that lost its name to them, which is the one whose column no longer matches the file.\n for (const signal of renamedByCollision(input.signals, columnNames)) {\n diagnostics.push({\n code: 'DUPLICATE_LABEL',\n severity: 'warning',\n message:\n `Signal ${signal.index} is labelled \"${signal.label}\", which is also the column name ` +\n `another channel's \"_ch\" suffix produces, so its column is \"${columnNames.get(signal.index)}\".`,\n hint: 'Column names are unique; look this channel up in channels.csv by its signal_index.',\n });\n }\n\n const range = resolveRange({\n start: options.start,\n startText: options.startText,\n duration: options.duration,\n end: options.end,\n endText: options.endText,\n recordDuration: input.recordDuration,\n recordCount: input.recordCount,\n recordStarts: input.recordStarts,\n });\n\n const writeSignals = options.annotationsOnly !== true;\n\n let chosen: EdfSignal[] = input.signals.filter((s) => !s.isAnnotations);\n\n /*\n Channel names are checked even under --annotations-only, where the selection is not\n otherwise used.\n\n Skipping the check meant `--channels TYPO --annotations-only` exited 0 in silence while\n the same typo without the flag was a usage error, and `--channels \"\"` stayed an error\n in both — so a mistyped name was the one form of bad input the tool accepted quietly.\n Everywhere else a term matching nothing is reported rather than ignored; a flag that\n happens not to apply is a poor reason to make an exception.\n */\n if (options.channels && options.channels.length > 0) {\n const selection = selectChannels(input.signals, options.channels);\n if (writeSignals) chosen = selection.signals;\n for (const { term, matched } of selection.ambiguous) {\n diagnostics.push({\n code: 'DUPLICATE_LABEL',\n severity: 'warning',\n message:\n `\"${term}\" matches ${matched.length} channels (positions ` +\n `${listed(matched.map((s) => `#${s.index}`))}); all of them were selected.`,\n hint: `Use --channels \"#${matched[0]?.index ?? 0}\" to pick just one.`,\n });\n }\n }\n\n const layout = options.layout ?? 'wide';\n const groups = writeSignals\n ? groupByRate(chosen, columnNames, options.decimals, options.gzip === true, layout)\n : [];\n const estimate = estimateOutput(\n groups,\n range,\n input.recordDuration,\n input.recordStarts,\n options.bom === true,\n layout,\n );\n\n /*\n The mixed-rate warning describes what this conversion does, not what the file holds.\n\n The header parser raises its own, which is right for `parseHeader` — but it sees every\n channel and knows nothing about `--channels`. Converting one channel out of a three-rate\n recording therefore announced \"3 different sampling rates ... written to one file per\n rate\" over a run that wrote one file, in the same output where `--info` had already\n marked the other two \"(not selected)\". Selecting two of the three was wrong the other\n way: still \"3\".\n\n Callers combining these with a file's own diagnostics drop that copy in favour of this\n one; see `withoutFileRateWarning`.\n */\n if (groups.length > 1) {\n diagnostics.push({\n code: 'MIXED_SAMPLING_RATES',\n severity: 'warning',\n message:\n `Channels use ${groups.length} different sampling rates ` +\n `(${listed(formatRates(groups.map((g) => g.rate)).map((r) => `${r} Hz`))}).`,\n hint:\n layout === 'long'\n ? 'They share one table, each row carrying its own time, so no channel is resampled.'\n : 'They are written to one file per rate so no channel is resampled.',\n });\n }\n\n /*\n A time column that cannot tell two samples apart.\n\n Sample times are written to at most nine decimal places, which separates everything up to\n a gigahertz. Below that the column repeats: a recording of 1 ns records holding ten\n samples each writes twenty rows carrying three distinct times, so joining or plotting on\n `time_s` silently collapses them. Nothing is lost from the file — every sample is there,\n in order — but the column stops being an identifier, and that is worth saying rather than\n leaving to be discovered.\n */\n for (const group of groups) {\n const step = group.rate > 0 ? 1 / group.rate : 0;\n if (step > 0 && step < 10 ** -group.timeDecimals) {\n diagnostics.push({\n code: 'TIME_RESOLUTION',\n severity: 'warning',\n message:\n `Channels at ${formatRate(group.rate)} Hz sample faster than the time column can ` +\n `distinguish, so consecutive rows in ${group.fileName} carry the same time_s value.`,\n hint:\n 'Every sample is written, in order. Use the row number rather than time_s to tell ' +\n 'them apart, or convert one rate at a time with --channels.',\n });\n }\n }\n\n /*\n The same failure as TIME_RESOLUTION, one column over.\n\n A channel whose quantization step is below 1e-98 needs more decimals than `toFixed` can\n print, so consecutive digital codes round to the same text and the arithmetic the FAQ\n gives for recovering them stops working. That used to happen at 1e-20 and silently — see\n MAX_DERIVED_DECIMALS. It is rare now, but \"rare\" is the reason to say so rather than the\n reason not to.\n\n Only when the precision was derived. `--decimals` exists to set a coarser one, so\n reporting the consequence of it is reporting the flag back at the caller who typed it:\n `--decimals 2` raised this on every channel of an ordinary EEG, and since --strict turns\n any diagnostic into exit 1, `--decimals 2 --strict` could not succeed on any recording.\n The warning is about a ceiling the caller cannot move, not about a floor they chose.\n */\n for (const group of options.decimals === undefined ? groups : []) {\n const short = group.channels.filter((c) => decimalsAreClamped(c.signal, c.decimals));\n if (short.length === 0) continue;\n diagnostics.push({\n code: 'VALUE_RESOLUTION',\n severity: 'warning',\n message:\n `${listed(short.map((c) => c.column))} ${short.length === 1 ? 'steps' : 'step'} by less ` +\n `than the ${short[0]?.decimals} decimals written can express, so some consecutive ` +\n `samples round to the same value in ${group.fileName}.`,\n hint:\n 'Every sample is written, in order, and the physical values are computed at full ' +\n 'precision either way. What is lost is only in the printed text.',\n });\n }\n\n if (estimate.exceedsSpreadsheetLimit) {\n diagnostics.push({\n code: 'LARGE_OUTPUT',\n severity: 'warning',\n message:\n `At least one output file will have more than ${SPREADSHEET_ROW_LIMIT.toLocaleString('en-US')} ` +\n `rows, which is more than Excel or Numbers can open.`,\n hint: 'Use --start and --duration to convert a section, or read the file with pandas or R.',\n });\n }\n\n return { groups, layout, gzip: options.gzip === true, range, columnNames, writeSignals, diagnostics, estimate };\n}\n\n/**\n * Partition channels by sampling rate, largest first.\n *\n * The common case — every channel at one rate — collapses to a single group and a\n * single `signals.csv`, so the honest behaviour costs nothing when there is nothing\n * to be honest about.\n */\nfunction groupByRate(\n signals: readonly EdfSignal[],\n columnNames: Map<number, string>,\n forcedDecimals: number | undefined,\n gzip: boolean,\n layout: 'wide' | 'long',\n): RateGroup[] {\n const byRate = new Map<number, EdfSignal[]>();\n for (const signal of signals) {\n // A channel with no samples has no sampling rate to group by, and would\n // otherwise produce an empty \"0hz\" file. The header parser already warned.\n if (signal.samplesPerRecord === 0) continue;\n const bucket = byRate.get(signal.samplingRate);\n if (bucket) bucket.push(signal);\n else byRate.set(signal.samplingRate, [signal]);\n }\n\n const rates = [...byRate.keys()].sort((a, b) => b - a);\n // The long layout writes one table whatever the rates are, so every group names it.\n const single = rates.length === 1 || layout === 'long';\n\n /*\n Two distinct rates can produce the same slug, because the slug rounds to six decimal\n places. Rates come from samplesPerRecord / recordDuration and every channel shares the\n record duration, so the closest two rates can be is 1 / recordDuration — which drops\n below 1e-6 once a record is longer than about eleven days. Absurd, but the header\n permits it, and the failure was silent and destructive: both groups opened a write\n stream on the same path, so the file ended up holding interleaved rows from both\n channels under a header naming only one of them.\n\n Distinct rates therefore get distinct files, always. The suffix is only ever reached by\n a collision, so ordinary recordings keep the names they have always had.\n\n Naming from the whole set of rates at once removes most of those collisions before the\n suffix has to. Rounding each rate on its own gave 1e-6 Hz and 1.25e-6 Hz the same slug,\n and the numbering below then produced signals_0_000001hz.csv and signals_0_000001hz_2.csv\n — two files that no longer overwrite each other, but of which only one is named for the\n rate it holds. The suffix stays as the backstop for anything this still cannot separate.\n */\n const suffix = gzip ? '.csv.gz' : '.csv';\n const slugs = formatRates(rates).map((text) => `${text.replace('.', '_')}hz`);\n const used = new Set<string>();\n const uniqueName = (index: number): string => {\n const base = `signals_${slugs[index]}`;\n let name = `${base}${suffix}`;\n for (let n = 2; used.has(name); n++) name = `${base}_${n}${suffix}`;\n used.add(name);\n return name;\n };\n\n return rates.map((rate, index) => {\n const members = byRate.get(rate) ?? [];\n const first = members[0];\n return {\n rate,\n samplesPerRecord: first ? first.samplesPerRecord : 0,\n fileName: single ? `signals${suffix}` : uniqueName(index),\n /*\n In the long layout every rate shares a `time_s` column, so they share its precision:\n the finest any of them needs. Writing 100 Hz at three places and 256 Hz at eight in\n the same column would make the column's meaning depend on the row.\n */\n timeDecimals: layout === 'long' ? Math.max(...rates.map(timeDecimals)) : timeDecimals(rate),\n channels: members.map((signal) => ({\n signal,\n column: columnNames.get(signal.index) ?? `signal_${signal.index}`,\n decimals: forcedDecimals ?? decimalsForSignal(signal),\n })),\n };\n });\n}\n\n/**\n * A file's diagnostics with the header's mixed-rate warning removed.\n *\n * `buildPlan` raises that warning for the channels actually being converted, so keeping both\n * would either duplicate it or contradict it. The header parser's copy stays where it is, for\n * callers reading a header without planning a conversion.\n */\nexport function withoutFileRateWarning(diagnostics: readonly Diagnostic[]): Diagnostic[] {\n return diagnostics.filter((d) => d.code !== 'MIXED_SAMPLING_RATES');\n}\n\n/** `256hz`, `12_5hz` — safe in a filename on every platform. */\nexport function rateSlug(rate: number): string {\n return `${formatRate(rate).replace('.', '_')}hz`;\n}\n\n/** Characters a fixed-decimal number of this magnitude occupies, sign included. */\nfunction widthOf(magnitude: number, decimals: number, signed = false): number {\n const size = Math.abs(magnitude);\n const sign = signed ? 1 : 0;\n const fraction = decimals > 0 ? 1 + decimals : 0;\n\n /*\n Cells are written with toFixed, which rounds. Taking the integer digits from the floor of\n the bound therefore under-counted whenever rounding carried into a new digit: a channel\n bounded at 9999.999 and written to zero decimals produces \"10000\", five characters where\n the floor of 9999.999 suggests four. Every cell on such a channel was a byte short, and\n `--info` reported 127 KB for a file that came out 131 KB.\n\n Measuring the bound as rendered removes that. toFixed switches to exponential notation\n past 1e21, so the arithmetic form still covers magnitudes beyond it.\n */\n if (!Number.isFinite(size)) return sign + 1 + fraction;\n if (size < 1e21) return sign + size.toFixed(Math.min(decimals, 100)).length;\n return sign + (Math.floor(Math.log10(size)) + 1) + fraction;\n}\n\nfunction estimateOutput(\n groups: readonly RateGroup[],\n range: ResolvedRange,\n recordDuration: number,\n recordStarts: Float64Array | null | undefined,\n bom: boolean,\n layout: 'wide' | 'long',\n): OutputEstimate {\n let rows = 0;\n let bytes = 0;\n let exceeds = false;\n // One table in the long layout, so the row limit applies to the sum rather than the\n // largest group, and the header and mark are counted once rather than once per group.\n let longRows = 0;\n\n for (const group of groups) {\n let groupRows = 0;\n for (let record = range.startRecord; record < range.endRecord; record++) {\n const recordStart = recordStarts\n ? (recordStarts[record] ?? record * recordDuration)\n : record * recordDuration;\n groupRows += countSamplesInRange({\n recordStart,\n rate: group.rate,\n samplesPerRecord: group.samplesPerRecord,\n startSeconds: range.startSeconds,\n endSeconds: range.endSeconds,\n });\n }\n if (layout === 'long') {\n // A row per sample per channel rather than a row per sample time.\n const groupCells = groupRows * group.channels.length;\n rows += groupCells;\n longRows += groupCells;\n /*\n `time_s,channel,value`: the time, the channel name as it will be escaped into the\n cell, and the widest the value can print. Same over-counting rule as the wide\n layout — the declared physical range bounds a cell, and most samples sit under it.\n */\n const timeWidth = widthOf(range.endSeconds, group.timeDecimals);\n for (const channel of group.channels) {\n const valueWidth = widthOf(\n Math.max(Math.abs(channel.signal.physicalMin), Math.abs(channel.signal.physicalMax)),\n channel.decimals,\n channel.signal.physicalMin < 0 || channel.signal.physicalMax < 0,\n );\n const nameWidth = Buffer.byteLength(escapeCsvField(channel.column));\n // Two commas and the newline.\n bytes += groupRows * (timeWidth + nameWidth + valueWidth + 3);\n }\n continue;\n }\n\n rows += groupRows;\n if (groupRows + 1 > SPREADSHEET_ROW_LIMIT) exceeds = true;\n\n /*\n Width per cell, from the channel's own calibration rather than a flat allowance.\n\n The old `decimals + 6` budgeted six characters for the sign, integer part and decimal\n point on every channel, whatever it actually held. That over-counted a millivolt\n channel spanning ±5 by four characters a cell and ran 30-55% high across the fixture\n set — on a number people use to decide whether a conversion is worth starting.\n\n The channel's declared physical range is what bounds a cell, so that bound is what is\n used. Most samples sit below it, so this still reads high, which is the direction a\n size estimate should err in.\n\n One case is outside the bound rather than under it: nothing obliges a recording to keep\n its samples inside the digital range it declares, and one that does not maps outside the\n physical range too. Such a file can convert larger than the estimate. Clamping the data\n to make the estimate true is not a trade worth making — the samples are what they are.\n */\n const timeWidth = widthOf(range.endSeconds, group.timeDecimals);\n const cellWidth = group.channels.reduce(\n (sum, c) =>\n sum +\n widthOf(\n Math.max(Math.abs(c.signal.physicalMin), Math.abs(c.signal.physicalMax)),\n c.decimals,\n c.signal.physicalMin < 0 || c.signal.physicalMax < 0,\n ),\n 0,\n );\n // One comma per channel, plus the newline.\n bytes += groupRows * (timeWidth + cellWidth + group.channels.length + 1);\n /*\n The header row, measured as it will be written rather than as the labels are stored.\n\n A column name is quoted when it contains a comma, a quote, a newline or a leading or\n trailing space, and every quote inside it is doubled. Counting the raw label under-counted\n that row: three channels labelled `a,b,c,d,e`, `x\"y` and `plain` write a 32-byte header\n and were budgeted 27. EDF labels are free text, so commas in them are ordinary — a montage\n written as `EEG Fpz-Cz, ref` is exactly the kind of thing this is for.\n\n csvRow is the function that writes it, so it is the function that measures it. Nothing\n else is in a position to stay correct when the quoting rules change.\n */\n bytes += Buffer.byteLength(csvRow(['time_s', ...group.channels.map((c) => c.column)])) + 1;\n // Three bytes per file under --bom. Small, but the estimate promises never to read\n // under what gets written, and a one-row conversion is small enough for it to matter.\n if (bom) bytes += BOM_BYTES;\n }\n\n if (layout === 'long' && groups.length > 0) {\n if (longRows + 1 > SPREADSHEET_ROW_LIMIT) exceeds = true;\n bytes += Buffer.byteLength(csvRow(['time_s', 'channel', 'value'])) + 1;\n if (bom) bytes += BOM_BYTES;\n }\n\n return { rows, bytes, exceedsSpreadsheetLimit: exceeds };\n}\n"]}
@@ -37,9 +37,10 @@ export interface ConvertOptions extends PlanOptions {
37
37
  /**
38
38
  * Write the signal CSV to stdout instead of to a directory.
39
39
  *
40
- * Only valid when the conversion produces exactly one signal file: a mixed-rate
41
- * recording becomes several tables, and merging them into one stream would mean
42
- * inventing the samples this tool exists not to invent. No sidecar files are written.
40
+ * Only valid when the conversion produces exactly one signal file. In the default wide
41
+ * layout a mixed-rate recording becomes several tables, and merging them into one stream
42
+ * would mean inventing the samples this tool exists not to invent; `layout: 'long'` gives
43
+ * one table for any recording, so it lifts the restriction. No sidecar files are written.
43
44
  */
44
45
  toStdout?: boolean | undefined;
45
46
  onProgress?: ((progress: ConversionProgress) => void) | undefined;
@@ -56,6 +57,14 @@ export interface WrittenFile {
56
57
  export interface ConvertResult {
57
58
  outputDir: string;
58
59
  files: WrittenFile[];
60
+ /**
61
+ * True when a `--stdout` reader closed the pipe before the conversion finished.
62
+ *
63
+ * `edf2csv rec.edf --stdout | head -1` is an ordinary thing to type and not a failure, but
64
+ * it is also not a conversion: the row count is rows formatted before the close was
65
+ * noticed, which is neither the recording's total nor what the reader received.
66
+ */
67
+ readerHungUp: boolean;
59
68
  annotationCount: number;
60
69
  diagnostics: Diagnostic[];
61
70
  plan: ConversionPlan;
@@ -1 +1 @@
1
- {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/convert/run.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAYH,OAAO,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAE3C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAanD,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAa,MAAM,WAAW,CAAC;AAGxE,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,eAAe,CAAC;AAExD,OAAO,EAAE,YAAY,EAAE,CAAC;AAExB,MAAM,MAAM,mBAAmB,GAC3B,eAAe,GACf,mBAAmB,GACnB,wBAAwB,GACxB,kBAAkB,GAClB,qBAAqB,GACrB,iBAAiB,GACjB,cAAc,CAAC;AAEnB;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,EAAE,WAAW,CAAC,mBAAmB,CAAoC,CAAC;AAEpG,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;gBACtB,IAAI,EAAE,mBAAmB,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY;CAM9F;AAED,MAAM,WAAW,cAAe,SAAQ,WAAW;IACjD,sFAAsF;IACtF,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,8CAA8C;IAC9C,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,wFAAwF;IACxF,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/B,UAAU,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE,kBAAkB,KAAK,IAAI,CAAC,GAAG,SAAS,CAAC;CACnE;AAED,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,WAAW,EAAE,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,IAAI,EAAE,cAAc,CAAC;IACrB,IAAI,EAAE,OAAO,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAYD,wBAAsB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,aAAa,CAAC,CAkLrG;AAsDD,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAG1D"}
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/convert/run.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAYH,OAAO,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAE3C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAmBnD,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAa,MAAM,WAAW,CAAC;AAGxE,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,eAAe,CAAC;AAExD,OAAO,EAAE,YAAY,EAAE,CAAC;AAExB,MAAM,MAAM,mBAAmB,GAC3B,eAAe,GACf,mBAAmB,GACnB,wBAAwB,GACxB,kBAAkB,GAClB,qBAAqB,GACrB,iBAAiB,GACjB,cAAc,CAAC;AAEnB;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,EAAE,WAAW,CAAC,mBAAmB,CAAoC,CAAC;AAEpG,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;gBACtB,IAAI,EAAE,mBAAmB,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY;CAM9F;AAED,MAAM,WAAW,cAAe,SAAQ,WAAW;IACjD,sFAAsF;IACtF,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,8CAA8C;IAC9C,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,wFAAwF;IACxF,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/B;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/B,UAAU,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE,kBAAkB,KAAK,IAAI,CAAC,GAAG,SAAS,CAAC;CACnE;AAED,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,WAAW,EAAE,CAAC;IACrB;;;;;;OAMG;IACH,YAAY,EAAE,OAAO,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,IAAI,EAAE,cAAc,CAAC;IACrB,IAAI,EAAE,OAAO,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAYD,wBAAsB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,aAAa,CAAC,CAuLrG;AAsDD,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAG1D"}
@@ -15,7 +15,7 @@ import path from 'node:path';
15
15
  import { EdfFile } from '../edf/reader.js';
16
16
  import { describeFormat, formatRates, formatWallClock } from '../edf/header.js';
17
17
  import { EdfError } from '../edf/errors.js';
18
- import { BufferedLineWriter, UTF8_BOM, csvRow, escapeCsvField } from '../format/csv.js';
18
+ import { BufferedLineWriter, DEFAULT_FLUSH_THRESHOLD, UTF8_BOM, csvRow, escapeCsvField, } from '../format/csv.js';
19
19
  import { listed } from '../format/list.js';
20
20
  import { fixed, makeSampleFormatter, makeTimeFormatter, newOffsetBudget, } from '../format/number.js';
21
21
  import { buildPlan, withoutFileRateWarning } from './plan.js';
@@ -96,7 +96,10 @@ export async function convert(inputPath, options = {}) {
96
96
  `(${listed(formatRates(plan.groups.map((g) => g.rate)).map((r) => `${r} Hz`))}).`, 'Narrow it to one rate with --channels, write --layout long to get them all in ' +
97
97
  'one table, or convert to a directory instead.');
98
98
  }
99
- const written = await writeSignalFiles(file, plan, null, timing.starts, options);
99
+ let readerHungUp = false;
100
+ const written = await writeSignalFiles(file, plan, null, timing.starts, options, (hungUp) => {
101
+ readerHungUp = hungUp;
102
+ });
100
103
  if (rowsIn(written) === 0)
101
104
  plan.diagnostics.push(emptyWindow(plan.range, file.recordCount));
102
105
  if (await file.changedSinceOpen())
@@ -104,6 +107,7 @@ export async function convert(inputPath, options = {}) {
104
107
  return {
105
108
  outputDir: '-',
106
109
  files: written,
110
+ readerHungUp,
107
111
  annotationCount: 0,
108
112
  diagnostics: [...withoutFileRateWarning(file.diagnostics), ...plan.diagnostics],
109
113
  plan,
@@ -173,6 +177,7 @@ export async function convert(inputPath, options = {}) {
173
177
  return {
174
178
  outputDir,
175
179
  files: written,
180
+ readerHungUp: false,
176
181
  annotationCount: annotationsWritten,
177
182
  diagnostics: [...withoutFileRateWarning(file.diagnostics), ...plan.diagnostics, ...stale],
178
183
  plan,
@@ -330,7 +335,7 @@ function describeFsError(cause) {
330
335
  return 'the path is too long';
331
336
  return cause instanceof Error ? cause.message : String(cause);
332
337
  }
333
- async function writeSignalFiles(file, plan, outputDir, recordStarts, options) {
338
+ async function writeSignalFiles(file, plan, outputDir, recordStarts, options, onHangUp) {
334
339
  // One budget for every table in this conversion, not one per table: see OffsetBudget.
335
340
  const offsets = newOffsetBudget();
336
341
  // Only the stdout path needs this; --out finds a full disk on its own, because it always
@@ -342,6 +347,21 @@ async function writeSignalFiles(file, plan, outputDir, recordStarts, options) {
342
347
  was about: two writers on one file interleave rows under a header naming one of them.
343
348
  */
344
349
  let shared = null;
350
+ /*
351
+ One buffer's worth of memory for the conversion, not one per table.
352
+
353
+ Every group had its own writer at the default 1 MiB threshold, so pending output was
354
+ group count × 1 MiB before anything drained. A 6.5 MB recording with 40 sampling rates
355
+ — the header allows thousands of channels, and a research montage really does mix a
356
+ dozen rates — died with a raw V8 heap out-of-memory and exit 134 under a 96 MB cap,
357
+ while the site advertises 48 MB. The recording is small; it is the fan-out that is not.
358
+
359
+ Split evenly with a floor, so the single-rate case, which is nearly every recording,
360
+ keeps exactly the buffer it always had, and forty tables cost 2.5 MB rather than 40.
361
+ The long layout shares one writer already and is unaffected either way.
362
+ */
363
+ const MIN_FLUSH_THRESHOLD = 64 * 1024;
364
+ const flushThreshold = Math.max(MIN_FLUSH_THRESHOLD, Math.floor(DEFAULT_FLUSH_THRESHOLD / Math.max(1, plan.groups.length)));
345
365
  const open = plan.groups.map((group, groupIndex) => {
346
366
  // A null directory means the single table goes to stdout. process.stdout is already a
347
367
  // writable stream, so the same buffered writer and backpressure handling apply.
@@ -353,9 +373,19 @@ async function writeSignalFiles(file, plan, outputDir, recordStarts, options) {
353
373
  compression and says nothing about what reached the descriptor. The compressor's own
354
374
  output is what stdout is handed, so that is what is counted. `pipe` uses a 'data'
355
375
  listener of its own and a second one is delivered the same chunks.
376
+
377
+ Once per stream, which in the long layout is once for all the groups. Attaching per
378
+ group put N listeners on the one shared compressor, so every chunk was counted N
379
+ times: `--stdout --layout long --gzip` on a 40-rate recording claimed 622,240 of
380
+ 622,240 bytes where 15,556 had been written, failed with a disk-full error over a
381
+ perfectly good file, and printed Node's MaxListenersExceededWarning to stderr on the
382
+ way past ten. 0.5.4 fixed the same arithmetic in the uncompressed branch and left
383
+ this one, because the uncompressed branch is where the count is a sum and this one is
384
+ where it is a subscription.
356
385
  */
357
- if (audit && stream !== target)
386
+ if (audit && stream !== target && !shared) {
358
387
  stream.on('data', (chunk) => audit.count(chunk.length));
388
+ }
359
389
  /*
360
390
  One writer, not one per group, when the table is shared. Separate writers over one
361
391
  stream each hold their own buffer and flush on their own schedule, so the rows would
@@ -363,7 +393,7 @@ async function writeSignalFiles(file, plan, outputDir, recordStarts, options) {
363
393
  were produced in, and the long layout's whole claim is that its rows are in time
364
394
  order.
365
395
  */
366
- const writer = shared?.writer ?? new BufferedLineWriter(stream);
396
+ const writer = shared?.writer ?? new BufferedLineWriter(stream, flushThreshold);
367
397
  if (plan.layout === 'long' && !shared)
368
398
  shared = { stream, settled, target, writer };
369
399
  // Only the first group writes the header of a shared table, and the mark before it.
@@ -397,12 +427,23 @@ async function writeSignalFiles(file, plan, outputDir, recordStarts, options) {
397
427
  // Uncompressed, the writer hands its bytes straight to the descriptor; compressed,
398
428
  // they were counted on the compressor's way out.
399
429
  if (options.gzip !== true) {
400
- for (const entry of open)
401
- audit.count(entry.writer.bytesOut);
430
+ /*
431
+ Once per writer, not once per group. The long layout gives every group the same
432
+ writer, so counting per group multiplied its byte total by the number of rates:
433
+ `--stdout --layout long` on a three-rate recording handed over 32,043 bytes, was
434
+ credited with 96,129, and failed with a disk-full error for a file that was
435
+ complete on disk. It only showed with stdout redirected to a regular file, since
436
+ that is the one case the audit applies to — which is the command the --layout
437
+ documentation gives.
438
+ */
439
+ for (const writer of new Set(open.map((entry) => entry.writer))) {
440
+ audit.count(writer.bytesOut);
441
+ }
402
442
  }
403
443
  if (!open.some((entry) => entry.writer.hungUp))
404
444
  audit.verify();
405
445
  }
446
+ onHangUp?.(open.some((entry) => entry.writer.hungUp));
406
447
  return written;
407
448
  }
408
449
  catch (cause) {
@@ -451,8 +492,9 @@ async function writeLongRecord(file, open, batch, recordInBatch, recordStart, ra
451
492
  if (!writer)
452
493
  return;
453
494
  const cursors = new Int32Array(open.length);
495
+ /* Reused across sample times so a three-million-row conversion allocates one of these. */
496
+ const due = [];
454
497
  for (;;) {
455
- let pick = -1;
456
498
  let earliest = Infinity;
457
499
  for (let g = 0; g < open.length; g++) {
458
500
  const entry = open[g];
@@ -462,32 +504,55 @@ async function writeLongRecord(file, open, batch, recordInBatch, recordStart, ra
462
504
  if (sample >= entry.group.samplesPerRecord)
463
505
  continue;
464
506
  const time = recordStart + sample / entry.group.rate;
465
- if (time < earliest) {
507
+ if (time < earliest)
466
508
  earliest = time;
467
- pick = g;
468
- }
469
509
  }
470
- if (pick < 0)
471
- return;
472
- const entry = open[pick];
473
- if (!entry)
510
+ if (earliest === Infinity)
474
511
  return;
475
- const sample = cursors[pick] ?? 0;
476
- cursors[pick] = sample + 1;
477
- // Same window rule as the wide layout, with the same per-rate slack.
478
- if (!sampleTimeIsInRange(earliest, range.startSeconds, range.endSeconds, toleranceFor(entry.group.rate))) {
512
+ /*
513
+ Everything at this instant, in the order the file declares its channels.
514
+
515
+ Groups are ordered by rate, largest first, because that is how the wide layout names
516
+ its files. Emitting a tie group by group therefore ordered the channels by descending
517
+ sampling rate — so a recording declaring `slow, medium, fast` wrote `fast, medium,
518
+ slow`, while the documentation promised file order and channels.csv listed file order.
519
+ Signal index is the file's own order, and the only one a reader can predict.
520
+ */
521
+ due.length = 0;
522
+ for (let g = 0; g < open.length; g++) {
523
+ const entry = open[g];
524
+ if (!entry)
525
+ continue;
526
+ const sample = cursors[g] ?? 0;
527
+ if (sample >= entry.group.samplesPerRecord)
528
+ continue;
529
+ if (recordStart + sample / entry.group.rate !== earliest)
530
+ continue;
531
+ cursors[g] = sample + 1;
532
+ // Same window rule as the wide layout, with the same per-rate slack.
533
+ if (!sampleTimeIsInRange(earliest, range.startSeconds, range.endSeconds, toleranceFor(entry.group.rate))) {
534
+ continue;
535
+ }
536
+ for (let c = 0; c < entry.group.channels.length; c++) {
537
+ due.push({ entry, groupIndex: g, sample, channel: c });
538
+ }
539
+ }
540
+ if (due.length === 0)
479
541
  continue;
542
+ if (due.length > 1) {
543
+ due.sort((a, b) => (a.entry.group.channels[a.channel]?.signal.index ?? 0) -
544
+ (b.entry.group.channels[b.channel]?.signal.index ?? 0));
480
545
  }
481
- const time = entry.formatTime(recordStart, sample);
482
- for (let c = 0; c < entry.group.channels.length; c++) {
483
- const channel = entry.group.channels[c];
484
- const format = entry.formatters[c];
546
+ for (const item of due) {
547
+ const channel = item.entry.group.channels[item.channel];
548
+ const format = item.entry.formatters[item.channel];
485
549
  if (!channel || !format)
486
550
  continue;
487
551
  if (writer.hungUp)
488
552
  return;
489
- writer.pushLine(`${time},${escapeCsvField(channel.column)},${format(file.sampleAt(batch, recordInBatch, channel.signal, sample))}`);
490
- entry.rows++;
553
+ writer.pushLine(`${item.entry.formatTime(recordStart, item.sample)},${escapeCsvField(channel.column)},` +
554
+ `${format(file.sampleAt(batch, recordInBatch, channel.signal, item.sample))}`);
555
+ item.entry.rows++;
491
556
  // Flushed inside the record for the same reason the wide layout is; see there.
492
557
  if (writer.full)
493
558
  await writer.flush();
@@ -578,7 +643,9 @@ async function streamSignalRows(file, plan, open, recordStarts, options) {
578
643
  options.onProgress({
579
644
  recordsDone,
580
645
  recordsTotal: endRecord - startRecord,
581
- bytesWritten: open.reduce((sum, g) => sum + g.writer.charsWritten, 0),
646
+ // Once per writer: the long layout's groups share one, so summing per group
647
+ // reported a figure larger than the file being written.
648
+ bytesWritten: [...new Set(open.map((entry) => entry.writer))].reduce((sum, entry) => sum + entry.charsWritten, 0),
582
649
  });
583
650
  }
584
651
  catch (cause) {