diff --git a/docs-source/_static/venue-calc.css b/docs-source/_static/venue-calc.css new file mode 100644 index 0000000..84a927d --- /dev/null +++ b/docs-source/_static/venue-calc.css @@ -0,0 +1,175 @@ +/* Mirrors camera-calc.css so the two calculators read as one control. Kept as + a separate selector tree rather than shared classes because the markup and + field sets differ; only the visual language is common. */ +.venue-calc { border: 1px solid var(--color-background-border, #ccc); border-radius: 6px; padding: 1em; margin: 1em 0; } +/* Whole-form starting points, above the first section. */ +.venue-calc-scenarios { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5em; + margin-bottom: 0.5em; + font-size: 0.9em; + font-weight: bold; +} +.venue-calc-scenarios + .venue-calc-note { margin: 0 0 1em; } +.venue-calc-group { border: 1px solid var(--color-background-border, #ddd); border-radius: 6px; margin: 0 0 1em 0; padding: 0.75em 1em 1em; } +.venue-calc-group legend { font-weight: bold; padding: 0 0.4em; } +.venue-calc-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.75em; } +.venue-calc-grid label { display: flex; flex-direction: column; font-size: 0.9em; gap: 0.25em; } +.venue-calc-grid label > span:first-child { display: flex; align-items: center; min-height: 1.4em; gap: 0.3em; } +.venue-calc-grid input, .venue-calc-grid select { padding: 0.3em; } +.venue-calc-grid input:disabled { + background: var(--color-background-secondary, #e9e9e9); + color: var(--color-foreground-muted, #767676); + border-color: var(--color-background-border, #ccc); + cursor: not-allowed; + opacity: 0.85; +} +.venue-calc-matrix { width: 100%; margin: 0.75em 0 0; font-size: 0.9em; border-collapse: collapse; } +.venue-calc-matrix th, .venue-calc-matrix td { + padding: 0.5em 0.7em; + text-align: left; + border: 1px solid var(--color-background-border, #ddd); +} +.venue-calc-matrix thead th { background: var(--color-background-secondary, #f3f3f3); } +.venue-calc-matrix input { width: 100%; max-width: 7em; padding: 0.3em; } +.venue-calc-matrix input:disabled { + background: var(--color-background-secondary, #e9e9e9); + color: var(--color-foreground-muted, #767676); + border-color: var(--color-background-border, #ccc); + cursor: not-allowed; + opacity: 0.85; +} +/* Row labels in the first column read as data labels, not headings — kept as + for screen readers but weighted like the cells beside them. */ +.venue-calc-matrix tbody th { font-weight: normal; } +.venue-calc-matrix tfoot th { + background: var(--color-background-secondary, #f3f3f3); + border-top: 2px solid var(--color-background-border, #ccc); +} +.venue-calc-matrix + .venue-calc-matrix { margin-top: 1em; } +.venue-calc-radio { display: flex; gap: 1.5em; margin: 0.75em 0; font-size: 0.9em; flex-wrap: wrap; } +.venue-calc-radio label { display: flex; align-items: center; gap: 0.4em; font-weight: normal; } +.venue-calc-note { font-size: 0.85em; opacity: 0.75; margin: 0.25em 0 0; } +/* Guidance meant to be read before touching the controls, so unlike a note it + keeps full contrast. */ +.venue-calc-lede { font-size: 0.9em; margin: 0 0 0.5em; } +/* A button sitting inline at the end of a lede sentence. */ +.venue-calc-lede .venue-calc-preset { margin-left: 0.5em; vertical-align: baseline; } +.venue-calc button { margin-top: 0.25em; padding: 0.5em 1.2em; cursor: pointer; } +.venue-calc-actions { display: flex; flex-wrap: wrap; gap: 0.5em; margin-top: 0.25em; } +.venue-calc-actions button { margin-top: 0; } +/* Nothing to export until Calculate has run once. */ +.venue-calc button:disabled { + cursor: not-allowed; + opacity: 0.5; +} +.venue-calc-preset { + margin-top: 0 !important; + padding: 0.3em 0.7em !important; + font-size: 0.85em; + background: var(--color-background-secondary, #f0f0f0); + color: var(--color-foreground-primary, #000) !important; + border: 1px solid var(--color-background-border, #ccc); + border-radius: 4px; +} +.venue-calc-details { + margin-top: 1.5em; + border-top: 1px solid var(--color-background-border, #ddd); + padding-top: 0.75em; +} +.venue-calc-details > summary { + cursor: pointer; + font-weight: bold; + font-size: 0.9em; + padding: 0.3em 0; + list-style-position: outside; +} +.venue-calc-details > summary:hover { color: var(--color-link, #0a7); } +.venue-calc-details[open] > summary { margin-bottom: 0.5em; } +/* Per-fieldset disclosures sit inside a bordered group that already separates + them, so they drop the rule and the heavier spacing the page-level one has. */ +.venue-calc-details-inline { + margin-top: 0.75em; + border-top: 0; + padding-top: 0; +} +.venue-calc-details-inline > summary { font-weight: normal; font-size: 0.85em; opacity: 0.8; } +.venue-calc-details-inline > summary:hover { opacity: 1; } +.venue-calc-details-inline .venue-calc-note:first-of-type { margin-top: 0; } +/* Advanced Configuration holds the numbers a planner normally shouldn't touch, + so its summary sits a shade quieter still than "Show calculations". */ +.venue-calc-advanced > summary { font-style: italic; } +.venue-calc-advanced .venue-calc-grid { margin-top: 0.25em; } +.venue-calc-calc-head { + font-size: 1em; + font-weight: bold; + margin: 1.25em 0 0.4em; +} +.venue-calc-calcs { width: 100%; font-size: 0.85em; margin: 0; border-collapse: collapse; } +.venue-calc-calcs th, .venue-calc-calcs td { + padding: 0.5em 0.7em; + text-align: left; + vertical-align: top; + border: 1px solid var(--color-background-border, #e5e5e5); +} +.venue-calc-calcs thead th { background: var(--color-background-secondary, #f3f3f3); } +/* Row labels name the output, the cells explain it — same weight as the + surrounding prose so the formula column stays the thing you read. */ +.venue-calc-calcs th[scope="row"] { font-weight: normal; width: 22%; } +.venue-calc-calcs td:last-child { width: 30%; opacity: 0.75; } +/* Two-column variant: no Source column, so the last cell is the working itself + and gets full width and full contrast. */ +.venue-calc-calcs-2col th[scope="row"] { width: 28%; } +.venue-calc-calcs-2col td:last-child { width: auto; opacity: 1; } +.venue-calc-calcs-2col td { line-height: 1.7; } +.venue-calc-output { margin-top: 1em; } +/* Shown in place of the tables once an input moves, so nobody reads figures + that no longer match the form. */ +.venue-calc-stale { + margin: 1em 0; + padding: 0.75em 1em; + border-left: 3px solid #9a6700; + background: var(--color-background-secondary, #f7f7f7); + font-size: 0.9em; +} +.venue-calc-out-head { + margin: 1.5em 0 0.5em; + padding-top: 0.5em; + border-top: 1px solid var(--color-background-border, #ddd); + font-size: 1.1em; + font-weight: bold; +} +.venue-calc-out-head:first-child { margin-top: 0; border-top: 0; padding-top: 0; } +.venue-calc-out-table { + width: 100%; + font-size: 0.9em; + margin: 0 0 0.75em; + border-collapse: collapse; +} +.venue-calc-out-table th, .venue-calc-out-table td { + padding: 0.5em 0.8em; + border: 1px solid var(--color-background-border, #ddd); +} +/* First column names the row; every other column is a figure, so it reads + right-aligned and column totals line up on the decimal. */ +.venue-calc-out-table thead th { + text-align: right; + font-weight: bold; + background: var(--color-background-secondary, #f3f3f3); +} +.venue-calc-out-table thead th:first-child { text-align: left; } +.venue-calc-out-table th[scope="row"] { text-align: left; font-weight: normal; } +.venue-calc-out-table td { text-align: right; } +.venue-calc-out-table .vc-subtotal { font-weight: bold; } +.venue-calc-out-table .vc-subtotal th, .venue-calc-out-table .vc-subtotal td { + background: var(--color-background-secondary, #f7f7f7); +} +.venue-calc-out-table .vc-total { color: #1a7f37; font-weight: bold; } +.venue-calc-out-table .vc-total th, .venue-calc-out-table .vc-total td { + border-top: 2px solid var(--color-background-border, #bbb); +} +.venue-calc-out-table .vc-warn { color: #9a6700; } +.venue-calc-out-note { font-size: 0.85em; opacity: 0.75; margin: 0 0 1em; } +.venue-calc-out-note.vc-warn { color: #9a6700; opacity: 1; } diff --git a/docs-source/_static/venue-calc.js b/docs-source/_static/venue-calc.js new file mode 100644 index 0000000..2ce1004 --- /dev/null +++ b/docs-source/_static/venue-calc.js @@ -0,0 +1,1196 @@ +// Loaded site-wide via html_js_files; no-op on pages without the calculator. +if (document.getElementById('vc-calc')) { + +const VC_SQFT_PER_SQM = 10.7639104; + +// Pit size bands. The first three are published in Venue Requirements; the +// fourth extends the same (2t + 2) x 3 formula past where they stop. +// +// `members` is only used for the competitor count, and is the band's midpoint +// — a band is all the size information enumerate mode is given, so there is +// nothing better to assume. The 19+ band is open-ended and has no midpoint, so +// 22 is a nominal stand-in. +const VC_SIZE_BANDS = [ + { key: '1', label: '1–3 members', tables: 1, members: 2 }, + { key: '2', label: '4–10 members', tables: 2, members: 7 }, + { key: '3', label: '11–18 members', tables: 3, members: 15 }, + { key: '4', label: '19+ members', tables: 4, members: 22 }, +]; + +const VC_DIVISIONS = ['a', 'b']; + +function vcNum(id) { + const v = parseFloat(document.getElementById(id).value); + return isNaN(v) ? 0 : v; +} + +function vcInt(id) { + const v = parseInt(document.getElementById(id).value, 10); + return isNaN(v) ? 0 : v; +} + +function vcChecked(id) { + return document.getElementById(id).checked; +} + +function vcRadio(name) { + return document.querySelector(`input[name="${name}"]:checked`).value; +} + +// --- Presets ------------------------------------------------------------ + +function vcSetVoltage(v, a) { + document.getElementById('vc-voltage').value = v; + document.getElementById('vc-amps').value = a; + vcInvalidate(); +} + +document.getElementById('vc-preset-na').addEventListener('click', function () { + vcSetVoltage(120, 20); +}); +document.getElementById('vc-preset-eu').addEventListener('click', function () { + vcSetVoltage(230, 16); +}); +document.getElementById('vc-preset-jp').addEventListener('click', function () { + vcSetVoltage(100, 15); +}); + +// --- Pit sizing --------------------------------------------------------- + +function vcClusterArea(tables) { + // t tables inline occupy 2t x 1 m, plus a shared 1 m buffer on every side. + if (tables <= 0) return 0; + return (2 * tables + 2) * 3; +} + +function vcTablesForMembers(members) { + // Same boundaries as the bands above. The published bands overlap at 10 — + // "4-10" and "10-18" both claim it — so 10 is resolved to the band whose + // label the matrix shows, 4-10. + if (members <= 3) return 1; + if (members <= 10) return 2; + if (members <= 18) return 3; + return 3 + Math.ceil((members - 18) / 8); +} + +// Returns { teams, tables, area, people } for one division. `mode` defaults to +// whichever team-entry mode is selected; passing it explicitly lets each table +// render its own footer from its own inputs, so the greyed-out one stays +// self-consistent rather than echoing the active mode's number. `people` is +// competitors only — no referees, organizers, volunteers or spectators. +function vcDivisionPits(div, mode) { + if ((mode || vcRadio('vc-team-mode')) === 'matrix') { + let teams = 0; + let tables = 0; + let area = 0; + let people = 0; + VC_SIZE_BANDS.forEach(function (band) { + const n = vcInt(`vc-mx-${div}-${band.key}`); + teams += n; + tables += n * band.tables; + area += n * vcClusterArea(band.tables); + people += n * vcNum(`vc-adv-members-${band.key}`); + }); + return { teams, tables, area, people }; + } + const teams = vcInt(`vc-teams-${div}`); + const members = vcNum(`vc-members-${div}`); + const tables = vcTablesForMembers(members); + return { + teams, + tables: teams * tables, + area: teams * vcClusterArea(tables), + people: teams * members, + }; +} + +// --- Mode sync and derived fields --------------------------------------- + +function vcSyncTeamMode() { + const matrix = vcRadio('vc-team-mode') === 'matrix'; + VC_DIVISIONS.forEach(function (div) { + document.getElementById(`vc-teams-${div}`).disabled = matrix; + document.getElementById(`vc-members-${div}`).disabled = matrix; + }); + VC_SIZE_BANDS.forEach(function (band) { + VC_DIVISIONS.forEach(function (div) { + document.getElementById(`vc-mx-${div}-${band.key}`).disabled = !matrix; + }); + }); +} + +function vcUpdateTeamTotals() { + // In average mode the matrix is a read-only echo of the plain team counts, + // collapsing each division into the one band its average lands in — a flat + // distribution to start editing from, not a claim about the real spread. + // Competitor counts do NOT carry across: enumerate mode has only the band to + // go on, so it uses band midpoints and its total will differ. + if (vcRadio('vc-team-mode') !== 'matrix') { + VC_DIVISIONS.forEach(function (div) { + const teams = vcInt(`vc-teams-${div}`); + const tables = vcTablesForMembers(vcNum(`vc-members-${div}`)); + const band = VC_SIZE_BANDS.find(b => b.tables === Math.min(tables, 4)) || VC_SIZE_BANDS[3]; + VC_SIZE_BANDS.forEach(function (b) { + document.getElementById(`vc-mx-${div}-${b.key}`).value = b.key === band.key ? teams : 0; + }); + }); + } + // The matrix's "Members assumed" column is a read-only echo of the advanced + // per-band figures, so changing one there can't leave the table contradicting + // the number actually used. + VC_SIZE_BANDS.forEach(function (band) { + document.getElementById(`vc-mem-disp-${band.key}`).textContent = + vcNum(`vc-adv-members-${band.key}`); + }); + VC_DIVISIONS.forEach(function (div) { + const byMatrix = vcDivisionPits(div, 'matrix'); + document.getElementById(`vc-mx-${div}-total`).textContent = byMatrix.teams; + document.getElementById(`vc-mx-${div}-people`).textContent = Math.round(byMatrix.people); + document.getElementById(`vc-avg-${div}-people`).textContent = + Math.round(vcDivisionPits(div, 'average').people); + }); +} + +function vcSyncFieldMode() { + const derive = vcRadio('vc-field-mode') === 'derive'; + ['vc-div-a', 'vc-div-b', 'vc-div-b-practice'].forEach(function (id) { + document.getElementById(id).disabled = derive; + }); +} + +// How many teams one field is planned to carry. Fixed rather than exposed as +// inputs: an organizer who disagrees with them is better served typing the +// field counts they want directly, which is what manual mode is for. +const VC_TEAMS_PER_FIELD_A = 10; +const VC_TEAMS_PER_FIELD_B = 12; +const VC_TEAMS_PER_PRACTICE_FIELD = 20; + +// Below this many teams sharing a match field, that field has enough idle time +// to practise on and a dedicated practice field isn't worth the floor space. +const VC_PRACTICE_SKIP_LOAD = 4; + +// Field N gets VLAN (10 x N) on 10.(10 x N).0.0/16, so the VLAN id and the +// second octet always match and a glance at an address says which field it is. +// Field 1 lands on 10.10.0.0/16, the worked example in Competition Network. +const VC_VLAN_STEP = 10; +// Far enough above the field range that it can't collide until 20 fields. +const VC_PITS_VLAN = 200; + +// Addresses held back below the DHCP pool for gateway, switches, routers and +// anything else that wants to sit at a fixed address. DHCP starts just above. +const VC_MGMT_RESERVED_TO = 50; + +// One chair per competitor in the pits, since everyone needs somewhere to sit +// at their own bench, and one per table at the field for whoever is operating +// it. Organizer tables get one each on top. +const VC_CHAIRS_PER_COMPETITOR = 1; +const VC_CHAIRS_PER_FIELD_TABLE = 1; + +// Camera systems the league owns and ships in its travel cases: enough for +// 4 Division A fields or 8 Division B fields. Anything past this the host has +// to source, and cameras and lenses are a long lead time item. +const VC_LEAGUE_CAMERA_SYSTEMS = 8; + +// One remote control per team at the field, so a field either has both or none. +const VC_REMOTES_PER_FIELD = 2; + +// A monitor each for the vision and game controller computers. They share their +// computer's table outlet via a power strip, so they add watts but not outlets. +const VC_MONITORS_PER_FIELD = 2; + +// Tables at a field: two for the field's own equipment (vision, game +// controller), then one per team the field serves, capped at what will +// physically fit around a field of that size. +const VC_FIELD_EQUIPMENT_TABLES = 2; +const VC_FIELD_TABLES_MAX_A = 8; +const VC_FIELD_TABLES_MAX_B = 6; + +// What the league's own travel cases turn up with, per field type. Must stay in +// step with the Field Equipment matrix's authored values, since Restore writes +// these back over whatever the page is showing. +const VC_FIELD_EQUIP_DEFAULTS = { + a: { cfg: 'nuc2', remotes: true, status: 2, audio: true, stream: true }, + b: { cfg: 'nuc1', remotes: true, status: 2, audio: true, stream: true }, + p: { remotes: false, status: 0, audio: false, stream: false }, +}; +const VC_POE_STATUS_DEFAULT = false; + +function vcRestoreFieldEquipment() { + Object.keys(VC_FIELD_EQUIP_DEFAULTS).forEach(function (key) { + const d = VC_FIELD_EQUIP_DEFAULTS[key]; + document.getElementById(`vc-remotes-${key}`).checked = d.remotes; + document.getElementById(`vc-status-${key}`).value = d.status; + document.getElementById(`vc-audio-${key}`).checked = d.audio; + document.getElementById(`vc-stream-${key}`).checked = d.stream; + // Practice fields borrow Division B's camera config and have no select. + if (d.cfg) document.getElementById(`vc-cfg-${key}`).value = d.cfg; + }); + document.getElementById('vc-poe-status').checked = VC_POE_STATUS_DEFAULT; +} + +document.getElementById('vc-equip-reset').addEventListener('click', function () { + vcRestoreFieldEquipment(); + vcInvalidate(); +}); + +// Added on top of pit footprint for aisles between rows. The published band +// areas are footprint only, with no room to walk between them. +const VC_PITS_CIRCULATION = 0.30; + +// Spectator seating, stage and registration. Not part of any league figure. +const VC_OTHER_AREA_SQM = 0; + +// Spare capacity on each field switch, before rounding up to a stock port +// count. Guards against a dead port and the odd unplanned device. +const VC_SPARE_PORT_HEADROOM = 20; + +// Drops that reach the outside world rather than a field: the streaming rigs +// and the organizers' desk. +const VC_INTERNET_DROPS = 2; + +// Every power figure the calculator uses, in one place. None of these are on +// the form: they're either set by regulation, published by the league, or +// nameplate estimates an event organizer has no way to improve on. Edit here to +// retune the model; the Power section's "Show calculations" lists the values so +// the page stays auditable without being editable. +const VC_POWER = { + // Fraction of a breaker's rating usable by a continuous load. Regulatory, + // not a preference. + circuitDerate: 0.80, + + // Applied to field-side nameplate load to estimate real draw. Roughly the + // league's own provisioned-to-average team ratio, since no equivalent average + // is published for field equipment. + diversity: 0.50, + + // Published in Venue Requirements: provision this much per team, plan the + // building aggregate on the average. + // Sized so a team can run a hot air rework station, not for continuous draw. + // 1400 rather than 1500 so two teams fit one 230 V / 16 A circuit, which + // halves the circuit count at European venues for no practical difference. + teamDropW: 1400, + teamAverageW: 750, + + // Nameplate / PSU-rating estimates against the minimum specs on the Compute + // Resources page. Not measurements. + devices: { + // Vision and game controller computers together, as one per-field figure. + // They remain two machines for outlet and switch-port counting. + visionAndGc: 500, + // Screens for those two computers. Counted separately so the computer + // figure stays comparable with the Compute Resources specs. + monitor: 45, + streaming: 750, + trussNuc: 90, + fieldSwitch: 30, + fieldRouter: 30, + // A status board is a display plus the small computer driving it. The + // display is always mains; only the computer can move onto PoE, so the + // two are costed separately. + statusBoardDisplay: 45, + statusBoardComputer: 15, + audioReferee: 100, + // Supplemental field lighting. Zero unless an event brings its own; a + // non-zero value also earns a floor outlet per field. + lightingPerField: 0, + }, + + // Drawn from a PoE source rather than an outlet. + poe: { + remoteControl: 25.5, + camera: 8, + statusBoard: 15, + }, +}; + +// --- Advanced Configuration -------------------------------------------- +// +// Every constant above is also an input, tucked behind a collapsed "Advanced +// Configuration" block in its section. The constants stay the single source of +// truth: on load their values are written into the inputs, and from then on the +// calculator reads the inputs. That way there's no second copy of a default to +// drift, and Restore just rewrites from the constant. +const VC_ADVANCED_GROUPS = { + teams: { + 'vc-adv-members-1': VC_SIZE_BANDS[0].members, + 'vc-adv-members-2': VC_SIZE_BANDS[1].members, + 'vc-adv-members-3': VC_SIZE_BANDS[2].members, + 'vc-adv-members-4': VC_SIZE_BANDS[3].members, + }, + fields: { + 'vc-adv-per-field-a': VC_TEAMS_PER_FIELD_A, + 'vc-adv-per-field-b': VC_TEAMS_PER_FIELD_B, + 'vc-adv-per-practice': VC_TEAMS_PER_PRACTICE_FIELD, + 'vc-adv-practice-skip': VC_PRACTICE_SKIP_LOAD, + }, + equipment: { + 'vc-adv-remotes-per-field': VC_REMOTES_PER_FIELD, + 'vc-adv-monitors-per-field': VC_MONITORS_PER_FIELD, + 'vc-adv-chairs-per-field-table': VC_CHAIRS_PER_FIELD_TABLE, + 'vc-adv-field-equip-tables': VC_FIELD_EQUIPMENT_TABLES, + 'vc-adv-field-tables-max-a': VC_FIELD_TABLES_MAX_A, + 'vc-adv-field-tables-max-b': VC_FIELD_TABLES_MAX_B, + }, + pits: { + 'vc-adv-chairs-per-competitor': VC_CHAIRS_PER_COMPETITOR, + 'vc-adv-circulation': VC_PITS_CIRCULATION * 100, + 'vc-adv-other-area': VC_OTHER_AREA_SQM, + }, + network: { + 'vc-adv-spare-ports': VC_SPARE_PORT_HEADROOM, + 'vc-adv-internet-drops': VC_INTERNET_DROPS, + }, + power: { + 'vc-adv-derate': VC_POWER.circuitDerate * 100, + 'vc-adv-diversity': VC_POWER.diversity, + 'vc-adv-team-drop': VC_POWER.teamDropW, + 'vc-adv-team-avg': VC_POWER.teamAverageW, + 'vc-adv-w-visiongc': VC_POWER.devices.visionAndGc, + 'vc-adv-w-stream': VC_POWER.devices.streaming, + 'vc-adv-w-nuc': VC_POWER.devices.trussNuc, + 'vc-adv-w-switch': VC_POWER.devices.fieldSwitch, + 'vc-adv-w-router': VC_POWER.devices.fieldRouter, + 'vc-adv-w-monitor': VC_POWER.devices.monitor, + 'vc-adv-w-status-display': VC_POWER.devices.statusBoardDisplay, + 'vc-adv-w-status-board': VC_POWER.devices.statusBoardComputer, + 'vc-adv-w-audio': VC_POWER.devices.audioReferee, + 'vc-adv-w-lighting': VC_POWER.devices.lightingPerField, + 'vc-adv-poe-remote': VC_POWER.poe.remoteControl, + 'vc-adv-poe-camera': VC_POWER.poe.camera, + 'vc-adv-poe-status': VC_POWER.poe.statusBoard, + }, +}; + +function vcRestoreAdvanced(group) { + const defaults = VC_ADVANCED_GROUPS[group]; + if (!defaults) return; + Object.keys(defaults).forEach(function (id) { + document.getElementById(id).value = defaults[id]; + }); +} + +Object.keys(VC_ADVANCED_GROUPS).forEach(vcRestoreAdvanced); + +document.querySelectorAll('[data-vc-adv-reset]').forEach(function (button) { + button.addEventListener('click', function () { + vcRestoreAdvanced(button.getAttribute('data-vc-adv-reset')); + vcRefresh(); + vcInvalidate(); + }); +}); + + +function vcUpdateDerivedFieldCounts() { + if (vcRadio('vc-field-mode') !== 'derive') return; + const teamsA = vcDivisionPits('a').teams; + const teamsB = vcDivisionPits('b').teams; + + const fieldsA = Math.ceil(teamsA / Math.max(1, vcNum('vc-adv-per-field-a'))); + const fieldsB = Math.ceil(teamsB / Math.max(1, vcNum('vc-adv-per-field-b'))); + document.getElementById('vc-div-a').value = fieldsA; + document.getElementById('vc-div-b').value = fieldsB; + + // Measured against the load each match field actually ends up with after the + // division rounds up, not the configured maximum — 5 teams at a cap of 12 is + // one lightly loaded field, not a full one. Divisions with no teams have no + // fields and don't vote. + const loads = []; + if (fieldsA > 0) loads.push(teamsA / fieldsA); + if (fieldsB > 0) loads.push(teamsB / fieldsB); + const skipAt = vcNum('vc-adv-practice-skip'); + const lightlyLoaded = loads.length > 0 && loads.every(l => l <= skipAt); + + document.getElementById('vc-div-b-practice').value = + lightlyLoaded ? 0 : Math.ceil((teamsA + teamsB) / Math.max(1, vcNum('vc-adv-per-practice'))); +} + +function vcRefresh() { + vcUpdateTeamTotals(); + vcUpdateDerivedFieldCounts(); +} + +document.querySelectorAll('input[name="vc-team-mode"]').forEach(function (radio) { + radio.addEventListener('change', function () { + vcSyncTeamMode(); + vcRefresh(); + }); +}); + +document.querySelectorAll('input[name="vc-field-mode"]').forEach(function (radio) { + radio.addEventListener('change', function () { + vcSyncFieldMode(); + vcRefresh(); + }); +}); + +['vc-teams-a', 'vc-members-a', 'vc-teams-b', 'vc-members-b'] + .concat(VC_SIZE_BANDS.flatMap(b => [`vc-mx-a-${b.key}`, `vc-mx-b-${b.key}`])) + .forEach(function (id) { + document.getElementById(id).addEventListener('input', vcRefresh); + }); + +// Browsers restore previously-checked radios on a plain page refresh, which +// doesn't fire 'change' — resync to whatever's actually checked now, not just +// the HTML's hardcoded defaults. +vcSyncTeamMode(); +vcSyncFieldMode(); +vcRefresh(); + +// --- Field geometry ----------------------------------------------------- + +function vcFieldFootprint(fw, fh, buffer) { + const w = fw + 2 * buffer; + const h = fh + 2 * buffer; + return { w, h, area: w * h }; +} + +function vcCameraConfig(cfg) { + if (cfg === 'direct4') return { cameras: 4, nucs: 0, direct: true }; + if (cfg === 'nuc2') return { cameras: 2, nucs: 2, direct: false }; + return { cameras: 1, nucs: 1, direct: false }; +} + +// Every field gets the same switch. Standardising on one size means one spare +// on the shelf fits any field, and 24 ports covers every configuration the +// league runs with headroom to spare. +const VC_FIELD_SWITCH_PORTS = 24; + +function vcSwitchCount(portsWithHeadroom) { + return Math.max(1, Math.ceil(portsWithHeadroom / VC_FIELD_SWITCH_PORTS)); +} + +// --- Render helpers ----------------------------------------------------- + +// The report is rebuilt on every Calculate and is what the CSV download is +// serialised from, so the file always matches exactly what's on screen. +let vcReport = []; + +// The headline numbers a host quotes at a venue, collected alongside the full +// report. Values are plain text, never HTML, since they go straight into +// Markdown and the clipboard. +let vcSummary = []; + +// `estimated` marks a headline whose value rests on a figure no league +// document states, so the Markdown export can say so rather than presenting +// every line with the same authority. +function vcSum(label, value, estimated) { + vcSummary.push({ label, value, estimated: !!estimated }); +} + +// "a", "a and b", "a, b and c". +function vcList(items) { + if (items.length <= 1) return items.join(''); + return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`; +} + +function vcHead(label) { + vcReport.push({ type: 'head', text: label }); + return `

${label}

`; +} + +function vcNote(text, warn) { + vcReport.push({ type: 'note', text }); + return `

${text}

`; +} + +// rows: arrays of cell strings, or { cells, cls } to style the whole row. +// A null row renders nothing, so callers can inline their conditionals. +function vcTable(headers, rows) { + vcReport.push({ + type: 'table', + headers, + rows: rows.filter(Boolean).map(r => (Array.isArray(r) ? r : r.cells)), + }); + const head = headers.map(h => `${h}`).join(''); + const body = rows.filter(Boolean).map(function (row) { + const cells = Array.isArray(row) ? row : row.cells; + const cls = Array.isArray(row) ? '' : (row.cls || ''); + const tds = cells.map(function (c, i) { + // First column labels the row; the rest are figures. + return i === 0 ? `${c}` : `${c}`; + }).join(''); + return `${tds}`; + }).join(''); + return `${head}${body}
`; +} + +// Areas round up, never to nearest: these are figures an organizer hands a +// venue, and rounding a floor requirement down under-books the room. Square +// feet are converted from the exact area before rounding, not from the +// already-rounded metric figure. +function vcSqm(sqm) { + return Math.ceil(sqm); +} + +function vcSqft(sqm) { + return Math.ceil(sqm * VC_SQFT_PER_SQM); +} + +function vcW(w) { + return w >= 1000 ? `${(w / 1000).toFixed(2)} kW` : `${w.toFixed(0)} W`; +} + +// --- Calculate ---------------------------------------------------------- + +function vcCalculate() { + vcRefresh(); + vcReport = []; + vcSummary = []; + + const pitsA = vcDivisionPits('a'); + const pitsB = vcDivisionPits('b'); + const teams = pitsA.teams + pitsB.teams; + const competitors = Math.round(pitsA.people + pitsB.people); + + const buffer = vcNum('vc-buffer'); + const cfgA = vcCameraConfig(document.getElementById('vc-cfg-a').value); + const cfgB = vcCameraConfig(document.getElementById('vc-cfg-b').value); + // `key` selects this field type's column in the Field Equipment matrix. + // `served` is the teams this field type has to seat; practice fields are + // shared by the whole event, match fields only by their own division. + const fieldGroups = [ + { label: 'Division A match', n: vcInt('vc-div-a'), fw: 13.4, fh: 10.4, cfg: cfgA, key: 'a', + div: 'a', served: pitsA.teams }, + { label: 'Division B match', n: vcInt('vc-div-b'), fw: 10.4, fh: 7.4, cfg: cfgB, key: 'b', + div: 'b', served: pitsB.teams }, + { label: 'Division B practice', n: vcInt('vc-div-b-practice'), fw: 10.4, fh: 7.4, cfg: cfgB, key: 'p', + div: 'b', served: teams }, + ]; + const activeGroups = fieldGroups.filter(g => g.n > 0); + const totalFields = activeGroups.reduce((sum, g) => sum + g.n, 0); + + const equipTables = vcNum('vc-adv-field-equip-tables'); + const fieldTableCap = { a: vcNum('vc-adv-field-tables-max-a'), b: vcNum('vc-adv-field-tables-max-b') }; + + // Tables at one field of this type: the field's own equipment tables plus one + // per team it serves, capped at what fits around a field that size. Returns + // the split so outlets can count all of them and switch ports only the team + // ones — the equipment tables' machines are already counted individually. + function vcFieldTables(g) { + const perField = g.n > 0 ? Math.ceil(g.served / g.n) : 0; + const cap = Math.max(equipTables, fieldTableCap[g.div]); + const total = Math.min(equipTables + perField, cap); + return { served: perField, equipment: equipTables, team: total - equipTables, total, capped: equipTables + perField > cap }; + } + + let html = ''; + + // --- Event scale ------------------------------------------------------ + + const teamMode = vcRadio('vc-team-mode') === 'matrix' ? 'enumerated' : 'from division averages'; + const fieldMode = vcRadio('vc-field-mode') === 'derive' ? 'derived from teams' : 'set directly'; + + html += vcHead('Event Scale'); + html += vcTable(['', 'Division A', 'Division B', 'Total'], [ + ['Teams', pitsA.teams, pitsB.teams, `${teams}`], + ['Competitors', Math.round(pitsA.people), Math.round(pitsB.people), `${competitors}`], + ['Pit tables', pitsA.tables, pitsB.tables, pitsA.tables + pitsB.tables], + ]); + html += vcNote(`Team sizes ${teamMode}. Competitors counts team members only, so referees, organizers, ` + + `volunteers and spectators are on top of it.`); + const camerasTotal = activeGroups.reduce((sum, g) => sum + g.cfg.cameras * g.n, 0); + html += vcTable(['Fields', 'Count', 'Cameras each', 'Cameras'], + activeGroups.map(g => [g.label, g.n, g.cfg.cameras, g.cfg.cameras * g.n]) + .concat([{ + cells: ['Total', `${totalFields}`, '', `${camerasTotal}`], + cls: 'vc-total', + }])); + html += vcNote(`Field counts ${fieldMode}.`); + vcSum('Teams', `${teams} (${pitsA.teams} Division A, ${pitsB.teams} Division B)`); + // Average mode multiplies the organizer's own roster figures; enumerate mode + // has only the band, so it falls back to midpoints. + vcSum('Competitors', `${competitors}`, vcRadio('vc-team-mode') === 'matrix'); + vcSum('Fields', `${totalFields} (${activeGroups.map(g => `${g.n} ${g.label}`).join(', ')})`, true); + vcSum('Camera systems', `${camerasTotal}`, true); + if (camerasTotal > VC_LEAGUE_CAMERA_SYSTEMS) { + html += vcNote( + `This needs ${camerasTotal} camera systems and the league owns ${VC_LEAGUE_CAMERA_SYSTEMS}. ` + + `Talk to the committees early, since cameras and lenses are a long lead time item.`, + true + ); + } + if (activeGroups.some(g => g.cfg.direct)) { + html += vcNote( + 'A direct wired configuration needs PoE cameras. The league does not own any, so those have to be ' + + 'sourced locally.', + true + ); + } + + // --- Floor area ------------------------------------------------------- + + let fieldArea = 0; + const areaRows = []; + activeGroups.forEach(function (g) { + const fp = vcFieldFootprint(g.fw, g.fh, buffer); + fieldArea += fp.area * g.n; + areaRows.push([ + `${g.label} × ${g.n}`, + `${fp.w.toFixed(1)} × ${fp.h.toFixed(1)} m`, + vcSqm(fp.area * g.n), + vcSqft(fp.area * g.n), + ]); + }); + + // At least one organizer table always exists; the form enforces it too. + const organizerTables = Math.max(1, vcInt('vc-organizer-tables')); + const organizerArea = vcClusterArea(organizerTables); + const teamsArea = pitsA.area + pitsB.area; + const circulation = vcNum('vc-adv-circulation') / 100; + const pitsArea = (teamsArea + organizerArea) * (1 + circulation); + const otherArea = vcNum('vc-adv-other-area'); + const totalArea = fieldArea + pitsArea + otherArea; + + html += vcHead('Floor Area'); + html += vcTable(['Area', 'Each', 'm²', 'sq.ft'], areaRows.concat([ + { cells: ['All fields', '', vcSqm(fieldArea), vcSqft(fieldArea)], cls: 'vc-subtotal' }, + pitsA.teams > 0 + ? [`Division A pits × ${pitsA.teams}`, `${vcSqm(pitsA.area / pitsA.teams)} m²`, + vcSqm(pitsA.area), vcSqft(pitsA.area)] + : null, + pitsB.teams > 0 + ? [`Division B pits × ${pitsB.teams}`, `${vcSqm(pitsB.area / pitsB.teams)} m²`, + vcSqm(pitsB.area), vcSqft(pitsB.area)] + : null, + [`Organizer tables × ${organizerTables}`, '', vcSqm(organizerArea), vcSqft(organizerArea)], + { cells: [`Pits subtotal, +${(circulation * 100).toFixed(0)}% circulation`, '', + vcSqm(pitsArea), vcSqft(pitsArea)], cls: 'vc-subtotal' }, + otherArea > 0 ? ['Other area', '', vcSqm(otherArea), vcSqft(otherArea)] : null, + { cells: ['Total floor area', '', `${vcSqm(totalArea)}`, + `${vcSqft(totalArea)}`], cls: 'vc-total' }, + ])); + vcSum('Total floor area', `${vcSqm(totalArea)} m\u00b2 (${vcSqft(totalArea)} sq.ft)`, true); + if (competitors > 0) { + // Not a code requirement — a sanity figure to check against whatever + // occupancy number the venue quotes, which counts heads, not equipment. + html += vcNote(`That works out to ${(totalArea / competitors).toFixed(1)} m² ` + + `(${(totalArea * VC_SQFT_PER_SQM / competitors).toFixed(0)} sq.ft) per competitor across ` + + `${competitors} of them. Worth checking against whatever occupancy limit the venue quotes.`); + } + + // --- Tables ----------------------------------------------------------- + + const fieldTableRows = activeGroups.map(function (g) { + const t = vcFieldTables(g); + return { + cells: [`${g.label} × ${g.n}`, t.served, t.equipment, t.team, t.total, t.total * g.n], + cls: t.capped ? 'vc-warn' : '', + }; + }); + const fieldTablesTotal = activeGroups.reduce((sum, g) => sum + vcFieldTables(g).total * g.n, 0); + const pitTablesTotal = pitsA.tables + pitsB.tables + organizerTables; + + html += vcHead('Tables and Chairs'); + html += vcTable(['At the fields', 'Teams served', 'Equipment', 'Team', 'Per field', 'Total'], + fieldTableRows.concat([ + { cells: ['All fields', '', '', '', '', `${fieldTablesTotal}`], cls: 'vc-total' }, + ])); + if (activeGroups.some(g => vcFieldTables(g).capped)) { + const cappedLabels = activeGroups.filter(g => vcFieldTables(g).capped).map(g => g.label); + html += vcNote(`${vcList(cappedLabels)} serve more teams than they have room for. ` + + `${fieldTableCap.a} tables fit a Division A field and ${fieldTableCap.b} fit a Division B one, so teams ` + + `there share a table between matches rather than each keeping one.`, true); + } + html += vcTable(['In the pits', 'Tables'], [ + pitsA.teams > 0 ? [`Division A teams × ${pitsA.teams}`, pitsA.tables] : null, + pitsB.teams > 0 ? [`Division B teams × ${pitsB.teams}`, pitsB.tables] : null, + ['Organizers', organizerTables], + { cells: ['All pits', `${pitTablesTotal}`], cls: 'vc-total' }, + ]); + html += vcTable(['Event total', 'Tables'], [ + { cells: ['Fields and pits', `${fieldTablesTotal + pitTablesTotal}`], cls: 'vc-total' }, + ]); + + const chairsPerCompetitor = vcNum('vc-adv-chairs-per-competitor'); + const chairsPerFieldTable = vcNum('vc-adv-chairs-per-field-table'); + const pitChairs = Math.ceil(competitors * chairsPerCompetitor) + organizerTables; + const fieldChairs = Math.ceil(fieldTablesTotal * chairsPerFieldTable); + html += vcTable(['Chairs', 'Each', 'Count'], [ + ['Competitors', `${chairsPerCompetitor} per competitor`, Math.ceil(competitors * chairsPerCompetitor)], + ['Organizers', '1 per table', organizerTables], + ['At the fields', `${chairsPerFieldTable} per table`, fieldChairs], + { cells: ['Total', '', `${pitChairs + fieldChairs}`], cls: 'vc-total' }, + ]); + vcSum('Tables', `${fieldTablesTotal + pitTablesTotal} (${fieldTablesTotal} at the fields, ` + + `${pitTablesTotal} in the pits)`, true); + vcSum('Chairs', `${pitChairs + fieldChairs}`, true); + + // --- Power ------------------------------------------------------------ + + const wVisionGc = vcNum('vc-adv-w-visiongc'); + const wStream = vcNum('vc-adv-w-stream'); + const wNuc = vcNum('vc-adv-w-nuc'); + const wSwitch = vcNum('vc-adv-w-switch'); + const wRouter = vcNum('vc-adv-w-router'); + const wMonitor = vcNum('vc-adv-w-monitor'); + const wStatusDisplay = vcNum('vc-adv-w-status-display'); + const wStatusBoard = vcNum('vc-adv-w-status-board'); + const monitorsPerField = vcNum('vc-adv-monitors-per-field'); + const wAudio = vcNum('vc-adv-w-audio'); + const wLighting = vcNum('vc-adv-w-lighting'); + + const poeStatus = vcChecked('vc-poe-status'); + + const poeRemoteW = vcNum('vc-adv-poe-remote'); + const poeCameraW = vcNum('vc-adv-poe-camera'); + const poeStatusW = vcNum('vc-adv-poe-status'); + + // Per field type, from the Field Equipment matrix. Remotes are all-or-nothing + // — one per team at the field, both teams or neither — and status boards are + // capped at the 3 the matrix allows. + const remotesPerField = vcNum('vc-adv-remotes-per-field'); + const vcRemotes = g => (vcChecked(`vc-remotes-${g.key}`) ? remotesPerField : 0); + const vcStatusBoards = g => Math.min(3, Math.max(0, vcInt(`vc-status-${g.key}`))); + const vcHasAudio = g => vcChecked(`vc-audio-${g.key}`); + const vcHasStream = g => vcChecked(`vc-stream-${g.key}`); + + // One streaming rig per field that's streaming, not one for the whole event. + const streamingCount = activeGroups.reduce((sum, g) => sum + (vcHasStream(g) ? g.n : 0), 0); + + const voltage = vcNum('vc-voltage'); + const amps = vcNum('vc-amps'); + const derate = vcNum('vc-adv-derate') / 100; + const circuitW = Math.max(1, voltage * amps * derate); + + // Status boards draw from PoE or from an outlet, never both. The display + // wattage is the outlet-side figure; the PoE figure is the board alone. + const perFieldGroundW = function (g) { + return wVisionGc + monitorsPerField * wMonitor + wSwitch + wRouter + wLighting + // The display draws mains either way; only the board itself can be PoE. + + vcStatusBoards(g) * wStatusDisplay + + (poeStatus ? 0 : vcStatusBoards(g) * wStatusBoard) + + (vcHasAudio(g) ? wAudio : 0); + }; + // Truss-mounted cameras draw from their NUC, so the NUC figure is the whole + // truss-side load; direct-wired cameras draw PoE from the vision computer. + const perFieldTrussW = function (g) { + return g.cfg.nucs * wNuc; + }; + + let fieldsConnected = 0; + let trussConnected = 0; + let fieldCircuits = 0; + const fieldPowerRows = []; + const fieldCircuitRows = []; + activeGroups.forEach(function (g) { + const ground = perFieldGroundW(g); + const truss = perFieldTrussW(g); + fieldsConnected += ground * g.n; + trussConnected += truss * g.n; + fieldPowerRows.push([`${g.label} × ${g.n}`, vcW(ground), vcW(truss), + vcW((ground + truss) * g.n)]); + // Circuits are counted per field rather than off the venue-wide total: + // fields are physically separated, and truss-height outlets are fed + // separately from floor-level ones, so neither shares a breaker with + // another field. + const groundCircuits = Math.ceil(ground / circuitW); + const trussCircuits = truss > 0 ? Math.ceil(truss / circuitW) : 0; + fieldCircuits += (groundCircuits + trussCircuits) * g.n; + fieldCircuitRows.push([`${g.label} × ${g.n}`, groundCircuits, trussCircuits, + (groundCircuits + trussCircuits) * g.n]); + }); + + const teamDropW = vcNum('vc-adv-team-drop'); + const teamAvgW = vcNum('vc-adv-team-avg'); + const streamingConnected = streamingCount * wStream; + const pitsConnected = teams * teamDropW; + const pitsAverage = teams * teamAvgW; + const diversity = vcNum('vc-adv-diversity'); + const fieldConnectedAll = fieldsConnected + trussConnected + streamingConnected; + const fieldAverage = fieldConnectedAll * diversity; + const totalConnected = fieldConnectedAll + pitsConnected; + const totalAverage = fieldAverage + pitsAverage; + + html += vcHead('Power'); + html += vcTable(['Fields', 'Floor, each', 'Truss, each', 'Connected'], fieldPowerRows.concat([ + streamingCount > 0 + ? [`Streaming computers × ${streamingCount}`, vcW(wStream), '', vcW(streamingConnected)] + : null, + { cells: ['All fields connected', '', '', vcW(fieldConnectedAll)], cls: 'vc-subtotal' }, + { cells: [`All fields expected (× ${diversity} diversity)`, '', '', vcW(fieldAverage)], + cls: 'vc-subtotal' }, + ])); + html += vcTable(['Pits', 'Per team', 'Teams', 'Total'], [ + ['Provisioned capacity', vcW(teamDropW), teams, vcW(pitsConnected)], + ['Expected draw', vcW(teamAvgW), teams, vcW(pitsAverage)], + ]); + html += vcTable(['Event total', 'Watts'], [ + { cells: ['Provisioned capacity', `${vcW(totalConnected)}`], cls: 'vc-total' }, + { cells: ['Expected draw', `${vcW(totalAverage)}`], cls: 'vc-total' }, + ]); + vcSum('Power provisioned', vcW(totalConnected), true); + vcSum('Power expected draw', vcW(totalAverage), true); + + // --- Circuits --------------------------------------------------------- + + // A team's drop is provisioned at its full rating, so pack whole teams into + // a circuit rather than dividing the aggregate — a circuit that can carry + // 1.9 team drops still only gets one team. + const teamsPerCircuit = teamDropW > 0 ? Math.floor(circuitW / teamDropW) : teams; + const pitsCircuits = teamsPerCircuit >= 1 + ? Math.ceil(teams / teamsPerCircuit) + : teams * Math.ceil(teamDropW / circuitW); + const streamCircuits = streamingCount > 0 ? Math.ceil(streamingConnected / circuitW) : 0; + + html += vcHead('Circuits'); + html += vcTable(['Circuits', 'Floor', 'Truss', 'Total'], fieldCircuitRows.concat([ + streamCircuits > 0 ? ['Streaming', streamCircuits, '', streamCircuits] : null, + teamsPerCircuit >= 1 + ? [`Pits, ${teamsPerCircuit} team${teamsPerCircuit === 1 ? '' : 's'} per circuit`, + pitsCircuits, '', pitsCircuits] + : { cells: [`Pits, ${Math.ceil(teamDropW / circuitW)} circuits per team drop`, + pitsCircuits, '', pitsCircuits], cls: 'vc-warn' }, + { cells: ['Total', '', '', `${pitsCircuits + fieldCircuits + streamCircuits}`], + cls: 'vc-total' }, + ])); + vcSum('Circuits', `${pitsCircuits + fieldCircuits + streamCircuits} at ` + + `${voltage} V / ${amps} A`, true); + html += vcNote(`Usable per circuit: ${vcW(circuitW)} ` + + `(${voltage} V × ${amps} A × ${(derate * 100).toFixed(0)}%). Fields are counted separately ` + + `from each other, and truss outlets separately from floor ones, since neither can share a breaker.`); + + // --- Outlets ---------------------------------------------------------- + + // One outlet per table per Competition Field; teams get one drop per + // cluster per Venue Requirements and bring their own power strip. + const pitsOutlets = teams + organizerTables; + let fieldGroundOutlets = 0; + let trussOutlets = 0; + const outletRows = []; + activeGroups.forEach(function (g) { + // One outlet per table, plus one for the switch and router between them. + let n = vcFieldTables(g).total + 1; + // One per status board whether or not it's on PoE: PoE powers the little + // computer, never the display hanging off it. + n += vcStatusBoards(g); + if (vcHasAudio(g)) n += 1; + if (wLighting > 0) n += 1; + // The streaming rig sits at the field it covers, so its outlet is floor + // level at that field rather than an event-wide extra. + if (vcHasStream(g)) n += 1; + fieldGroundOutlets += n * g.n; + trussOutlets += g.cfg.nucs * g.n; + outletRows.push([`${g.label} × ${g.n}`, n, g.cfg.nucs, (n + g.cfg.nucs) * g.n]); + }); + + const totalOutlets = pitsOutlets + fieldGroundOutlets + trussOutlets; + + html += vcHead('Outlets'); + html += vcTable(['Outlets', 'Floor, each', 'Truss, each', 'Total'], outletRows.concat([ + { cells: ['Field floor', '', '', fieldGroundOutlets], cls: 'vc-subtotal' }, + { cells: ['Field truss', '', '', trussOutlets], cls: 'vc-subtotal' }, + { cells: ['Pits floor', '', '', pitsOutlets], cls: 'vc-subtotal' }, + { cells: ['Total', '', '', `${totalOutlets}`], cls: 'vc-total' }, + ])); + vcSum('Outlets', `${totalOutlets} (${fieldGroundOutlets} field floor, ${trussOutlets} field truss, ` + + `${pitsOutlets} pits floor)`, true); + if (trussOutlets > 0) { + html += vcNote('Field truss outlets are one per truss NUC, at mounting height. Price them with the rigging ' + + 'rather than as floor drops.', true); + } + + // --- Network ---------------------------------------------------------- + + const spare = 1 + vcNum('vc-adv-spare-ports') / 100; + const networkRows = []; + let totalSwitchPorts = 0; + let totalSwitches = 0; + let totalPoeW = 0; + let hostAdapterPoeW = 0; + + activeGroups.forEach(function (g) { + // Cameras never land on the field switch. In the truss config they hang + // off their NUC; in the direct config they hang off the vision computer's + // PoE host adapter. Either way the switch sees one port per NUC, or none. + let ports = g.cfg.nucs; + ports += 2; // vision computer, game controller computer + ports += vcRemotes(g); + ports += vcStatusBoards(g); + ports += vcFieldTables(g).team; + ports += vcHasAudio(g) ? 1 : 0; + ports += vcHasStream(g) ? 1 : 0; + ports += 1; // router / venue uplink + const sized = Math.ceil(ports * spare); + totalSwitchPorts += ports * g.n; + + const switchPoe = vcRemotes(g) * poeRemoteW + (poeStatus ? vcStatusBoards(g) * poeStatusW : 0); + totalPoeW += switchPoe * g.n; + if (g.cfg.direct) hostAdapterPoeW += g.cfg.cameras * poeCameraW * g.n; + + const switches = vcSwitchCount(sized); + totalSwitches += switches * g.n; + networkRows.push({ + cells: [ + `${g.label} × ${g.n}`, + ports, + VC_FIELD_SWITCH_PORTS * switches - ports, + switches > 1 ? `${switches} × ${VC_FIELD_SWITCH_PORTS}-port` : `${VC_FIELD_SWITCH_PORTS}-port`, + vcW(switchPoe), + ], + cls: switches > 1 ? 'vc-warn' : '', + }); + }); + + const pitsDropsPerTeam = vcInt('vc-pits-drops'); + const pitsDrops = teams * pitsDropsPerTeam; + const internetDrops = vcInt('vc-adv-internet-drops'); + const venueDrops = totalFields + pitsDrops + organizerTables + internetDrops; + + html += vcHead('Network'); + html += vcTable(['Field switches', 'Ports used', 'Spare', 'Buy', 'PoE budget'], + networkRows.concat([ + { cells: ['All fields', totalSwitchPorts, '', '', vcW(totalPoeW)], cls: 'vc-subtotal' }, + ])); + if (hostAdapterPoeW > 0) { + html += vcNote(`A further ${vcW(hostAdapterPoeW)} of PoE comes from the vision ` + + `computers' host adapters, for direct wired cameras, rather than from the field switches.`); + } + html += vcTable(['Venue-provided drops', 'Each', 'Count'], [ + ['Field uplinks', '1 per field', totalFields], + ['Pits', `${pitsDropsPerTeam} per team`, pitsDrops], + ['Organizer tables', '1 per table', organizerTables], + ['Internet', '', internetDrops], + { cells: ['Total', '', `${venueDrops}`], cls: 'vc-total' }, + ]); + + // One VLAN per field is the isolation boundary the league needs: multicast + // and broadcast from one field must never reach another. The pits VLAN + // carries no league traffic, so it's a convenience rather than a requirement. + const pitsVlan = pitsDropsPerTeam > 0 ? 1 : 0; + + function vcVlanRow(label, octet) { + return { + cells: [ + label, + octet, + `10.${octet}.0.0/16`, + `10.${octet}.0.1`, + `10.${octet}.0.${VC_MGMT_RESERVED_TO + 1} to 10.${octet}.255.249`, + ], + cls: octet > 250 ? 'vc-warn' : '', + }; + } + + let fieldIndex = 0; + const vlanRows = []; + activeGroups.forEach(function (g) { + for (let i = 1; i <= g.n; i += 1) { + fieldIndex += 1; + vlanRows.push(vcVlanRow(g.n > 1 ? `${g.label} ${i}` : g.label, VC_VLAN_STEP * fieldIndex)); + } + }); + if (pitsVlan > 0) vlanRows.push(vcVlanRow('Pits (optional)', VC_PITS_VLAN)); + + html += vcTable(['VLANs', 'VLAN', 'Subnet', 'Gateway', 'DHCP pool'], vlanRows.concat([ + { cells: ['Total', `${totalFields + pitsVlan}`, '', '', ''], cls: 'vc-total' }, + ])); + html += vcNote( + `VLAN id matches the second octet, so an address says which field it is. Per field: .0.1 gateway, ` + + `.0.2 through .0.${VC_MGMT_RESERVED_TO} reserved for fixed addresses, .255.255 broadcast, IPv4 only.` + ); + if (VC_VLAN_STEP * fieldIndex > 250) { + html += vcNote( + 'Past 25 fields this numbering runs out of second octet, so renumber by hand from there.', true + ); + } + html += vcNote( + 'One per field is required, since vision and referee multicast must not cross fields. ' + + (pitsVlan > 0 + ? 'The pits VLAN is optional; ask for it anyway.' + : 'No pits VLAN: the pits have no wired drops.') + ); + + vcSum('Network drops from the venue', `${venueDrops}`, true); + vcSum('Field switches', `${totalSwitches} at ${VC_FIELD_SWITCH_PORTS} ports`, true); + vcSum('VLANs', `${totalFields + pitsVlan}`); + + document.getElementById('vc-output').innerHTML = html; + vcSetExportsEnabled(true); +} + +// Results go stale the moment any input moves, so the tables are cleared and +// the exports switched off rather than left showing numbers that no longer +// match the form. +function vcSetExportsEnabled(enabled) { + ['vc-download', 'vc-download-md', 'vc-copy'].forEach(function (id) { + document.getElementById(id).disabled = !enabled; + }); +} + +function vcInvalidate() { + if (vcReport.length === 0) return; + vcReport = []; + vcSummary = []; + document.getElementById('vc-output').innerHTML = + '

Inputs changed. Press Calculate to update the results.

'; + vcSetExportsEnabled(false); +} + +const vcRoot = document.querySelector('.venue-calc'); +if (vcRoot) { + ['input', 'change'].forEach(function (evt) { + vcRoot.addEventListener(evt, vcInvalidate); + }); +} + +document.getElementById('vc-calc').addEventListener('click', vcCalculate); + +// --- Scenarios ----------------------------------------------------------- + +// Whole-form starting points. Everything not listed here is whatever the +// section defaults already are, since applying a scenario resets the form +// first — except mains voltage and breaker rating, which follow the country +// the venue is in rather than the size of the event. +const VC_SCENARIOS = { + international: { teamsA: 8, membersA: 12, teamsB: 12, membersB: 7 }, + 'regional-a': { teamsA: 4, membersA: 12, teamsB: 0, membersB: 7 }, + 'regional-b': { teamsA: 0, membersA: 12, teamsB: 4, membersB: 7 }, +}; + +const VC_SCENARIO_BASE = { organizerTables: 2, bufferM: 1.5, pitsDropsPerTeam: 1 }; + +function vcCheckRadio(name, value) { + document.querySelectorAll(`input[name="${name}"]`).forEach(function (radio) { + radio.checked = radio.value === value; + }); +} + +function vcApplyScenario(key) { + const scenario = VC_SCENARIOS[key]; + if (!scenario) return; + + Object.keys(VC_ADVANCED_GROUPS).forEach(vcRestoreAdvanced); + vcRestoreFieldEquipment(); + vcCheckRadio('vc-team-mode', 'average'); + vcCheckRadio('vc-field-mode', 'derive'); + + document.getElementById('vc-teams-a').value = scenario.teamsA; + document.getElementById('vc-members-a').value = scenario.membersA; + document.getElementById('vc-teams-b').value = scenario.teamsB; + document.getElementById('vc-members-b').value = scenario.membersB; + document.getElementById('vc-organizer-tables').value = VC_SCENARIO_BASE.organizerTables; + document.getElementById('vc-buffer').value = VC_SCENARIO_BASE.bufferM; + document.getElementById('vc-pits-drops').value = VC_SCENARIO_BASE.pitsDropsPerTeam; + + vcSyncTeamMode(); + vcSyncFieldMode(); + vcCalculate(); +} + +document.querySelectorAll('[data-vc-scenario]').forEach(function (button) { + button.addEventListener('click', function () { + vcApplyScenario(button.getAttribute('data-vc-scenario')); + }); +}); + +// --- CSV download -------------------------------------------------------- + +// Cells are built for HTML, so strip markup and turn the handful of entities +// the report uses back into plain characters before they reach a spreadsheet. +const VC_ENTITIES = { + '×': 'x', '²': '2', '—': '-', '–': '-', + '→': '->', '⌈': '', '⌉': '', '&': '&', ' ': ' ', +}; + +function vcPlain(cell) { + let text = String(cell === null || cell === undefined ? '' : cell) + .replace(/<[^>]+>/g, ''); + Object.keys(VC_ENTITIES).forEach(function (ent) { + text = text.split(ent).join(VC_ENTITIES[ent]); + }); + return text.replace(/\s+/g, ' ').trim(); +} + +function vcCsvCell(value) { + const text = vcPlain(value); + return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +function vcReportToCsv() { + const lines = [ + ['SSL Venue Requirements Calculator'], + ['Generated', new Date().toISOString().slice(0, 10)], + ].map(r => r.map(vcCsvCell).join(',')); + + vcReport.forEach(function (entry) { + if (entry.type === 'head') { + lines.push(''); + lines.push(vcCsvCell(entry.text)); + return; + } + if (entry.type === 'note') { + lines.push(vcCsvCell(`Note: ${entry.text}`)); + return; + } + lines.push(entry.headers.map(vcCsvCell).join(',')); + entry.rows.forEach(row => lines.push(row.map(vcCsvCell).join(','))); + }); + return lines.join('\r\n'); +} + +function vcSummaryToMarkdown() { + const lines = [ + '# SSL Venue Requirements', + '', + `Generated ${new Date().toISOString().slice(0, 10)} by the SSL Venue Requirements Calculator.`, + '', + ]; + vcSummary.forEach(function (entry) { + lines.push(`- **${vcPlain(entry.label)}:** ${vcPlain(entry.value)}${entry.estimated ? ' (est.)' : ''}`); + }); + lines.push(''); + lines.push('Lines marked (est.) rest on assumptions this calculator makes rather than on a published league'); + lines.push('requirement. Everything else follows from figures the league documents or from what you entered.'); + lines.push(''); + lines.push('Planning figures for a venue conversation, not an electrical design. Have an electrician or the'); + lines.push("venue's own facilities team confirm circuit counts and layout."); + return lines.join('\n'); +} + +function vcSaveFile(text, filename, mime) { + const blob = new Blob([text], { type: mime }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +function vcStamp() { + return new Date().toISOString().slice(0, 10); +} + +document.getElementById('vc-download-md').addEventListener('click', function () { + if (vcSummary.length === 0) return; + vcSaveFile(vcSummaryToMarkdown(), `ssl-venue-summary-${vcStamp()}.md`, 'text/markdown;charset=utf-8;'); +}); + +document.getElementById('vc-copy').addEventListener('click', function () { + if (vcSummary.length === 0) return; + const button = this; + const restore = function (message) { + const original = button.textContent; + button.textContent = message; + setTimeout(function () { button.textContent = original; }, 1500); + }; + // The clipboard API needs a secure context, so a docs build opened over + // file:// will reject. Say so rather than failing silently. + if (!navigator.clipboard) { + restore('Copy unavailable'); + return; + } + navigator.clipboard.writeText(vcSummaryToMarkdown()) + .then(function () { restore('Copied'); }) + .catch(function () { restore('Copy failed'); }); +}); + +document.getElementById('vc-download').addEventListener('click', function () { + if (vcReport.length === 0) return; + // The BOM is what makes Excel read the file as UTF-8 rather than latin-1. + vcSaveFile('\uFEFF' + vcReportToCsv(), `ssl-venue-plan-${vcStamp()}.csv`, 'text/csv;charset=utf-8;'); +}); + +} diff --git a/docs-source/conf.py b/docs-source/conf.py index 5927370..af75b5a 100644 --- a/docs-source/conf.py +++ b/docs-source/conf.py @@ -38,8 +38,8 @@ html_theme = "furo" html_static_path = ["_static"] -html_css_files = ["site.css", "camera-calc.css"] -html_js_files = ["camera-calc.js"] +html_css_files = ["site.css", "camera-calc.css", "venue-calc.css"] +html_js_files = ["camera-calc.js", "venue-calc.js"] # Diagrams using `layout: elk` in their frontmatter need the ELK layout # plugin explicitly opted into — sphinxcontrib-mermaid doesn't load it by diff --git a/docs-source/events.md b/docs-source/events.md new file mode 100644 index 0000000..2f5a22e --- /dev/null +++ b/docs-source/events.md @@ -0,0 +1,49 @@ +# Events and Hosting Documentation + +This is the landing page for all things related to running SSL events and event hosting. If you're a participating team +looking for information about how to join our community or attend an event, please see the official +[SSL website](https://ssl.robocup.org) or [SSL Discord Server](https://discord.com/invite/t724m8c). This documentation +covers how to run an event, not how to attend. + +## Supporting an Existing Event + +### Setup and Teardown Items + +- [Pelican/Travel Case Packing](events/travelcases.md) + +## Hosting a New Event + +Hosting a new event can be challenging, but is made substantially easier with the support of the +[league committees](https://ssl.robocup.org/robocups/robocup-2026/robocup-2026-committees/), and a local university. If +you haven't already, please contact the +[organizing committee (rc-ssl-oc@lists.robocup.org)](mailto:rc-ssl-oc@lists.robocup.org) so we can help you. + +The committee recommends starting no later than 6-months out from the desired event date for regional events and no +later than 12-months out for large international events. This timeline is mostly due to venue availability being limited +on short notice. + +This page provides summary level resources to facilitate choosing a venue and supporting personnel. This wiki has +extensive technical documentation on [field](field.md) and [network](protocol.md) subsystems. + +## Early Stages + +A good first step would be to identify a head organizer and a technical lead. These individuals would then build a +"local organizing committee" and a "local technical committee". + +### Choosing a Venue + +- [Venue Requirements](events/venuerequirements.md) +- [Venue Requirements Calculator](events/venue-calculator.md) + +## Example Layouts + +```{toctree} +--- +maxdepth: 1 +caption: Contents +--- +events/venuerequirements.md +events/venue-calculator.md +events/setupguide.md +events/travelcases.md +``` diff --git a/docs-source/events/images/C2L1.jpg b/docs-source/events/images/C2L1.jpg new file mode 100644 index 0000000..ea9d9ce Binary files /dev/null and b/docs-source/events/images/C2L1.jpg differ diff --git a/docs-source/events/images/C2L2.jpg b/docs-source/events/images/C2L2.jpg new file mode 100644 index 0000000..54e79c3 Binary files /dev/null and b/docs-source/events/images/C2L2.jpg differ diff --git a/docs-source/events/images/C3L1.jpg b/docs-source/events/images/C3L1.jpg new file mode 100644 index 0000000..ccb4b56 Binary files /dev/null and b/docs-source/events/images/C3L1.jpg differ diff --git a/docs-source/events/images/C3L2.jpg b/docs-source/events/images/C3L2.jpg new file mode 100644 index 0000000..5566059 Binary files /dev/null and b/docs-source/events/images/C3L2.jpg differ diff --git a/docs-source/events/images/C3L3.jpg b/docs-source/events/images/C3L3.jpg new file mode 100644 index 0000000..218a80c Binary files /dev/null and b/docs-source/events/images/C3L3.jpg differ diff --git a/docs-source/events/images/cameras_on_truss.jpg b/docs-source/events/images/cameras_on_truss.jpg new file mode 100644 index 0000000..cec05a1 Binary files /dev/null and b/docs-source/events/images/cameras_on_truss.jpg differ diff --git a/docs-source/events/setupguide.md b/docs-source/events/setupguide.md new file mode 100644 index 0000000..b1faedc --- /dev/null +++ b/docs-source/events/setupguide.md @@ -0,0 +1,15 @@ +# Field Setup Guide + +Help wanted! + +Especially picture describing the setup in detail. + +## Pre-Arrival to the Venue + +Ensure you have a schedule created with the + +## Setup Days + +## Event Days + +## Teardown diff --git a/docs-source/events/travelcases.md b/docs-source/events/travelcases.md new file mode 100644 index 0000000..52bc554 --- /dev/null +++ b/docs-source/events/travelcases.md @@ -0,0 +1,89 @@ +# Pelican/Travel Case Packing + +The SSL owns 4 hard-shell travel cases, colloquically called "Pelicans", that are reused for each global event as well +as some of the regionals. They typically go home with a reliable team or TC/OC member for transport to the next +regional/global event. This page documents their contents and the system they use to ensure event setup and teardown is +easy. + +## Top Level Description + +The cases mostly carry the cameras, lenses, truss computers, and cabling required to setup field vision systems and +streaming. + +The league currently has 8 field camera systems (camera + lens + nuc + accessories) which can support 4 Division A +fields or 8 Division B fields. We use 5 total systems at most international events (1 Div A, 2 Div B, 1 Div B practice) +which leaves 3 spare systems. This gives us spares and the ability to run any one field redundantly if we need to test +different vision versions or configurations concurrently. + +The cases are externally labeled as follows: + +- Case 1: Equipment SSL Set 1 +- Case 2: Equipment SSL Set 2 +- Case 3: Cables SSL Set 2 (roughly 21.6kg) +- Case 4: Cables SSL Set 1 (roughly 21.5kg) + +[Laminated Packing Inserts](https://docs.google.com/document/d/1DBrpu4QNOSp4uC7DHPw7sSZ3TgjXhoRiypexUmNRG-8/edit?usp=sharing). + +## Organizational System + +The league currently owns 4 pelicans, two large, two small. They are each colored, which is the first key aspect of how +they are organized. The big pelicans are pink and orange and have a big piece of colored gaff tape on the side. The +small pelicans are green and blue and also have a piece of gaff tape on the side. Some ancillary streaming equipment is +brought by TIGERs Mannheim and this case is labeled yellow. + +**Every case, camera, computer, and cable that is league owned is labeled with colored tape.** Every items always comes +from and goes back into the case of the corresponding color. + +If you are helping with teardown, you can always move an unknown item next to the case of the corresponding color, even +if you don't know where it goes yet. Likewise, if you are following pictures in a setup guide or someone asks for an +items "from the pink case" you'll know what they mean when they make that request. + +Every case should have a laminated sheet fully enumerating the items in the case and including picture of how the case +is to be packed at every layer. The cases are very full, so it's essential that you pack the case the way the pictures +describe. These sheets can also help you located a needed piece of equipment. + +[Laminated Packing Inserts](https://docs.google.com/document/d/1DBrpu4QNOSp4uC7DHPw7sSZ3TgjXhoRiypexUmNRG-8/edit?usp=sharing). + +## Example Camera Setup + +Here's an example of two cameras in a redundant configuration on a Division A truss (one field half) in Incheon 2026. +Note the colored tape on the cameras, cables, and mounting arms. That all corresponds to the above mentioned cases. In +this picture, camera mount arms are from the green pelican, the cameras are from the orange, and one USB3 cable is from +the pink pelican. + +![Two cameras in a redundant configuration on a Division A truss at Incheon 2026, with colored tape on the cameras, cables, and mounting arms](images/cameras_on_truss.jpg) + +## Photos from Packing Lists + +You'll notice the following photos (taken from the laminated packing sheets) shows layer by layer color coordinated +packing as described. + +### Case 2 + +Here's an example of packing Case 2, a large pelican with camera compute and streaming cameras and lenses. All of this +guidance should also be included in the laminated packing insert. + +Layer 1 + +![Case 2, layer 1](images/C2L1.jpg) + +Layer 2 + +![Case 2, layer 2](images/C2L2.jpg) + +### Case 3 + +Here's an example of packing Case 3, a small pelican with cables and mounting hardware. All of this guidance should be +included in the laminated packing insert. + +Layer 1 + +![Case 3, layer 1](images/C3L1.jpg) + +Layer 2 + +![Case 3, layer 2](images/C3L2.jpg) + +Layer 3 + +![Case 3, layer 3](images/C3L3.jpg) diff --git a/docs-source/events/venue-calculator.md b/docs-source/events/venue-calculator.md new file mode 100644 index 0000000..685381c --- /dev/null +++ b/docs-source/events/venue-calculator.md @@ -0,0 +1,586 @@ +# Venue Requirements Calculator + +Sizes a venue for an SSL event: floor area, power, outlets, and network drops. + +Start from the number of teams you expect. If you venue or event is different, check the "advanced configuration" +dropdown for each section to override any simplifying assumptions that don't work for you. + +This calculator automatically uses [Venue Requirements](venuerequirements.md) to generate a summary document for you. +Defaults are sourced where a league document states the number and estimated where it doesn't. Values are rounded +conservatively. Open **Show calculations** under the results for any section for a more detail explanation of what's +going on. As always, feel free to reach out to the TC/OC for help using this page. + +Results are planning figures for a venue conversation, not an electrical design. Have an electrician or the venue's own +facilities team confirm circuit counts and layout. + +AI Disclosure: This calculator was built with the assistance of Claude Opus 5. Please verify any outputs before sending +to the venue. + +
+
+ Start from: + + + +
+

Each resets every section, including anything you changed under Advanced Configuration, + and calculates straight away. Mains voltage and breaker rating are left alone, since those follow the country + rather than the event.

+
+ 1. Teams +
+ + +
+ + + + + + + + + + + + + + + + + + + +
Division ADivision B
Teams
Average members per team
Competitors9684
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Team sizeMembers assumedDivision ADivision B
1-3 members2
4-10 members7
11-18 members15
19+ members22
Total teams812
Competitors9684
+
+ Show calculations + + + + + + + + + +
Tables per team1 up to 3 members, 2 up to 10, 3 up to 18, then +1 per 8 more
Pit area per teamtables are 2 × 1 m, placed inline, with a shared 1 m buffer all round:
+ (2 × tables + 2) × 3 m²
+ 1 table → 4 × 3 = 12 m²
+ 2 tables → 6 × 3 = 18 m²
+ 3 tables → 8 × 3 = 24 m²
+ 4 tables → 10 × 3 = 30 m²
+ Published, except the 19+ band
Competitorsteams × members per team, both divisions added together
+

Competitors counts team members only. Referees, organizers, volunteers, and spectators + are on top of it and aren't estimated anywhere on this page.

+
+
+ Advanced Configuration +
+ + + + +
+

Only affects the competitor estimate in enumerate mode. Pit tables and pit area depend on the band alone.

+
+ +
+
+
+ +
+ 2. Fields +

How many fields you need follows from how many teams have to share one. Plan on at most + 10 Division A teams per match field, 12 Division B teams per match field, and + 20 teams per practice field. If every match field ends up with 4 teams or fewer, no practice field + is recommended, since the match fields already have enough idle time to practise on.

+
+ + +
+
+ + + +
+
+ +
+
+ Show calculations +

Field counts. + Division A match fields = ⌈ Division A teams ÷ 10 ⌉. + Division B match fields = ⌈ Division B teams ÷ 12 ⌉. + Practice fields = ⌈ all teams ÷ 20 ⌉, or zero when every match field carries 4 teams or fewer. + That 4-team test uses the load each field actually ends up with after its division rounds up, so 5 Division B + teams counts as one lightly loaded field rather than a full one. Practice fields are Division B sized. The three + ratios are fixed, sized against what the international event runs, per + Competition Network. Switch to Set field counts directly + if your event doesn't fit them.

+

Field footprint. Field sizes are the SSL Rules field of play + including the boundary margin, so 13.4 by 10.4m for Division A and 10.4 by 7.4m for Division B. The + buffer is the space outside that for tables, cabling, and people, and tables will encroach on it. Footprint is + exactly field plus twice the buffer, so at the recommended 1.5m that's 16.4 by 13.4m for Division A and 13.4 by + 10.4m for Division B. Areas are rounded up.

+
+
+ Advanced Configuration +
+ + + + +
+
+ +
+
+
+ +
+ 3. Field Equipment +

Defaults reflect the equipment the league ships in its + travel cases. +

+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Division ADivision BPractice
Remote controls (2 per field)
Status boards per field
Audio referee
Streaming computer
+
+ +
+
+ Show calculations +

Per-field equipment. Every field gets a vision computer, a game + controller computer, and its own switch and router regardless of camera config. In the direct wired config the + cameras land on the vision computer's PoE host adapter rather than the field switch, so they consume neither a + switch port nor switch PoE budget. See + Competition Network.

+

Remote controls. One per team at the field, so a field has either + two or none. There's no configuration where one team gets a remote and the other doesn't. They need PoE+, per + Compute Resources.

+

Tables at the field. Two for the field's own equipment, then one per + team the field serves, capped at 8 tables around a Division A field and 6 around a Division B one. A field past + its cap means teams share a table between matches. Each table takes an outlet, and each team table takes a switch + port; the equipment tables' machines are counted individually instead.

+

Streaming computers. One per field that streams, not one for the + event, since each rig switches between that field's cameras and overlays that field's game state. Each takes a + port on its own field's switch and is the only machine guaranteed an internet drop, per + Competition Field.

+

League cameras. The league owns 8 camera systems, enough for 4 + Division A fields or 8 Division B fields, and ships them in its + travel cases. Anything past that has to be sourced locally, as do PoE cameras for + a direct wired configuration, which the league doesn't own. Both show a warning in the results.

+

Why Division A has no single-camera option. One camera can't cover a + Division A field: it would need close to four times the resolution and a mount higher than most venues can + provide, so the league doesn't run that configuration. See + Competition Field.

+
+
+ Advanced Configuration +
+ + + + + + +
+
+ +
+
+
+ +
+ 4. Team Area / The Pits +
+ +
+

Each team's pit is sized from the size band you gave it in section 1:

+ + + + + + + + + + +
Team sizePit tablesPit area
1-3 members112 m²
4-10 members218 m²
11-18 members324 m²
19+ members430 m²
+
+ Show calculations +

Pit area. Tables are 2 × 1 m, placed inline, with a shared 1 m + buffer all round: (2 × tables + 2) × 3 m². Organizer tables form one more + cluster on the same formula. The bands and their areas are + published, except the 19+ band, which extends the same formula.

+

Circulation. A fixed 30% is added on top of all pit footprints for + aisles between rows. That allowance is not in the published figures, which are pit footprint + only with no room to walk between them.

+

Chairs. One per competitor, so everyone has somewhere to sit at + their own bench, plus one per organizer table and one per table at the field. Chairs don't affect the pit area, + which is set by the tables.

+

Not counted. Spectator seating, stage, and registration are no part + of this total. Add them to whatever the venue quotes you separately.

+
+
+ Advanced Configuration +
+ + + +
+
+ +
+
+
+ +
+ 5. Power +

Only the supply matters here. Everything the league plugs into it is a fixed + estimate, listed under Show calculations.

+
+ + +
+
+ + + +
+
+ Show calculations + + + + + + + + + + + + + + + +
Usable per circuitvolts × amps × 80%. The derate is the standard continuous-load allowance, + set by wiring regulation rather than by preference
Per team1400 W provisioned, 750 W expected draw, per + Venue Requirements
Field diversityfield nameplate load × 0.5 for expected draw, roughly the provisioned to + average team ratio, since no equivalent average is published for field equipment
Mains-powered, per fieldvision + game controller computers together 500 W, plus a + 45 W monitor each
+ field switch 30 W · field router 30 W
+ status board display 45 W each, always mains, since PoE powers the board and never the + screen hanging off it
+ status board computer 15 W each, when not on PoE
+ audio referee 100 W · supplemental lighting 0 W
Mains-powered, elsewheretruss NUC 90 W each, at mounting height. Truss cameras draw from their NUC
+ streaming computer 750 W each
PoE-poweredremote control 25.5 W (PoE+) · camera 8 W · + status board 15 W when on PoE
+

Device figures are nameplate / PSU-rating estimates against the minimum specs on + Compute Resources, not measured draw. Vision and game controller are + one combined 500 W figure because they're always deployed as a pair; they still count as two machines for outlets + and switch ports.

+
+
+ Advanced Configuration +
+ + + + + + + + + + + + + + + + + +
+
+ +
+
+
+ +
+ 6. Network +
+ +
+
+ Show calculations +

Ports versus drops. Field switch ports are what the field's own + switch must have. Venue drops are what the venue or its networking contractor has to physically pull: one uplink + per field, plus pits and internet. Set pits drops to 0 if the pits get no wired network.

+

Switches. Every field gets the same 24-port switch whatever it + actually needs, so one spare on the shelf fits any field. No configuration the league runs comes close to + filling it. A field only gets a second switch if its ports plus headroom exceed 24.

+

VLANs. One per field, plus an optional one for the pits. Per-field + isolation is a hard requirement, because vision and referee traffic is multicast and two fields sharing a + broadcast domain will consume each other's game state. A networking contractor will usually quote virtual + isolation more cheaply than a router per field; see + Competition Network. The pits VLAN carries + no league traffic and is a convenience only.

+

Addressing. The suggested VLAN ids and subnets are a convention + rather than a rule, and a contractor with an existing numbering plan should use theirs. What matters is that each + field lands in its own VLAN and its own subnet. Layout follows + Competition Network, holding back .0.2 through .0.50 + for the gateway, switch, router and anything else wanting a fixed address.

+
+
+ Advanced Configuration +
+ + +
+
+ +
+
+
+ +
+ + + + +
+ +
+ +
+Show calculations + +

Every formula this page uses, and where each number comes from. "Published" means a league +document states it, so follow the link and cite it to your venue. "Estimated" means this calculator picked it, and you +should replace it with a real figure once you have one.

+ +

Teams and pits

+ + + + + + + + + + + + + + + +
Pit tables per team1 table to 3 members, 2 to 10, 3 to 18, then +1 per 8 membersPublished, 19+ band estimated
Pit area per team(2 × tables + 2) × 3 m², from 2 by 1m tables inline sharing a 1m bufferPublished
Competitors, average modeteams × average members, per divisionYour input
Competitors, enumerate modeΣ (teams in band × band midpoint), using 2, 7, 15, 22Estimated. No league document states a roster size
+ +

Fields

+ + + + + + + + + +
Match fields⌈ division teams ÷ teams per match field ⌉, at 10 Division A / 12 Division B per fieldEstimated, sized against the international event's field mix
Practice fields⌈ total teams ÷ 20 ⌉, or none at all when every match field carries 4 teams or fewerEstimated
+ +

Floor area

+ + + + + + + + + + + + + + + + + + +
Field of play13.4 × 10.4 m Division A, 10.4 × 7.4 m Division B, boundary margin includedPublished
Field footprinteach dimension plus twice the buffer, so 16.4 by 13.4m for Division A and 13.4 by 10.4m for Division B at + the recommended 1.5mPublished at a 1.5 m buffer
Pits(Σ team pit areas + organizer pit area) × 1.30Areas published. The 30% circulation is estimated, since the published figures have no aisles
Totalall field footprints + pits; sq.ft = m² × 10.764. Both are rounded up to whole units for display, + never down. Spectator seating, stage, and registration are not included
Per competitortotal area ÷ competitors, to compare against the venue's occupancy limitSanity check, not a code requirement
+ +

Power

+ + + + + + + + + + + + + + + + + + +
Per field, floor levelvision and game controller together 500 W, + switch 30 W + router 30 W + lighting, plus 60 W per status board + not on PoE, plus 100 W audio referee where presentDevices published, wattages estimated
Per field, truss leveltruss NUCs × 90 W. Truss cameras draw from their NUCConfig published, wattage estimated
Field expected drawconnected load × 0.5 diversityEstimated, roughly the provisioned to average team ratio
Pits capacityteams × 1400 W provisioned; teams × 750 W expectedPublished
Where the figures livenone of the above are on the form, since they're regulatory, published, or nameplate estimates. All of them + sit in one VC_POWER structure in the page's script, and the Power section lists themEstimated against the minimum specs on + Compute Resources, not measured
+ +

Circuits

+ + + + + + + + + + + + +
Usable per circuitvolts × amps × derateEstimated. 80% is the usual continuous load derate
Pits circuitswhole team drops packed per circuit, never split, so a circuit holding 1.9 drops still takes one teamEstimated
Field circuitscounted per field, floor and truss separately, since separated fields can't share a breakerEstimated
+ +

Outlets and network drops

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pits outletsone per team, one per organizer table. Teams bring their own power stripPublished
Chairsone per competitor, one per organizer table, one per table at the fieldEstimated. No league document covers seating
Field outletsone per table at the field, which covers the vision and game controller computers and their monitors on a + strip, plus one for the switch and router, one per status board whether or not the board is on PoE, one for the + audio referee, one for the streaming rig where a field has one, and one for lighting if any. Totalled as field + floor, field truss and pits floor, since those are three separate asks of the venuePublished, one outlet per table
Truss outletsone per truss NUC, at mounting height. Budget rigging for these rather than floor outletsFollows from the published camera config
Field switch portstruss NUCs + vision + game controller + remote controls + status boards + field team tables + audio referee + + uplink. Every field gets the same 24-port switch, so one spare fits any field; the headroom allowance only decides + whether a field needs a second oneDevices published; headroom and the 24-port standard estimated
Switch PoE budgetremote controls × 25.5 W, plus status boards if on PoE. Cameras are never on the field switchPublished, remotes need PoE+
Venue dropsone uplink per field + pits drops per team + organizer tables + internet dropsEstimated. League documentation sets no pits network requirement
VLANsone per field, required for isolation, plus one optional VLAN covering the pitsPer-field isolation + published; the pits VLAN is estimated
Addressingfield N gets VLAN 10N on 10.(10N).0.0/16, so the VLAN id and second octet match; + pits take VLAN 200. Gateway .0.1, reserved .0.2 through .0.50, DHCP from .0.51, broadcast .255.255Subnet layout published; the numbering scheme is + this calculator's suggestion
+ +

Not counted anywhere above: referees, organizers, volunteers, and spectators, along with +whatever space, seating, and facilities they need. Competitor count covers teams only.

+ +
+
diff --git a/docs-source/events/venuerequirements.md b/docs-source/events/venuerequirements.md new file mode 100644 index 0000000..82a27b3 --- /dev/null +++ b/docs-source/events/venuerequirements.md @@ -0,0 +1,109 @@ +# Venue Requirements for Hosting SSL Competitions + +Venue requirements are broken down into a few main categories. + +The [Venue Requirements Calculator](venue-calculator.md) does the arithmetic on this page for you, and extends it to +power, outlet, and network drop counts. + +## Size + +The first major question for a venue is "does it have enough room"? + +A Division A field is 13.4 by 10.4m. We recommend a 1.5m buffer on all sides (tables will encroach), bringing the +recommended footprint to 16.4m x 13.4m, 220sq.m, or 2366sq.ft. + +A Division B field is 10.4 by 7.4m. We recommend a 1.5m buffer on all sides (tables will encroach), bringing the +recommended footprint to 13.4m x 10.4m, 140sq.m, or 1501sq.ft. + +Planning the team area is a bit more involved. Starting from a baseline that each team brings two or three members, +organizers should plan to provide a 2m x 1m table with a 1m buffer zone. Bigger teams get another table, placed inline +with the first so some of the buffer zone is shared. This is a ballpark, so don't agonize over a team that lands near a +boundary; the averages wash out across an event. + +Budget the following square footage for the team area: + +- 1-3 Member Team: 1 table, 12sq.m (130sq.ft) +- 4-10 Member Team: 2 tables, 18sq.m (194sq.ft) +- 11-18 Member Team: 3 tables, 24sq.m (259sq.ft) +- 19+ Member Team: 4 tables, 30sq.m (323sq.ft) + +Include at least one table for the event organizers. + +You will also need to plan to provide tables at the field as per the [field technical documentation](../field.md). + +## Type + +The venue generally needs to support events of this nature. It's preferable that the venue have a concrete or plastic +tile floor. While it's important that you don't represent the event as industrial and dangerous, the venue owner needs +to understand that handtools and wear & tear on the space will exceed that of a normal "corporate event". It's better to +get this cleared ahead of time than to find out later they cannot support your event. Feel free to include pictures of +previous events the league has held. + +## Setup and Teardown + +Remember to book the venue for setup and teardown, not just the days you're competing. A small event running on its own +can be set up in a day, and a full international event needs two. Plan on two either way if you're sharing the venue +with other leagues, since the extra time goes on waiting for shared rigging and networking staff rather than on our own +work. + +Teardown can be done in a half day, but only with a plan. It's worth making that plan, because the saved day usually +shows up directly in the venue quote. Venues often price setup days differently than event days, so it's worth asking +early rather than finding out at the end. + +Rigging is usually done by whichever contractor the venue already works with, so you won't be shopping around on +schedule. Ask what their lead time and availability look like while you're still negotiating dates. + +## Field Support + +The venue needs to be able to support trusses or permit other camera mounting at approximately 6-6.5m in height. This is +typically done by a rigging company with a truss, though some venues may already have trussing. The committees are +working hard to support a system where overhead cameras may be replaced by side mount cameras but we cannot guarantee +support for this yet and it is currently experimental. + +One camera setup weighs approximately 2.5kg or 5lbs. This is under the limit where a licensed rigger is required to +fasten the equipment to the truss, but you should consult the [field setup guide](setupguide.md). + +The league owns 8 camera systems, enough for 4 Division A fields or 8 Division B fields, and typically 5 of them are in +use at an international event. See the [travel case documentation](travelcases.md) for what ships. If your event needs +more fields than that covers, talk to the committees early, since cameras and lenses are a long lead time item. Note +also that the league cameras are not PoE. A direct wired camera configuration needs PoE cameras sourced locally. + +## Lighting + +Ideally the venue is windowless and has controllable overhead lighting. It's also important to confirm the overhead +lighting does not exhibit 50/60Hz flicker, though this is rare in most venues. If the venue has windows, it will almost +certainly mandate the use of SSL Vision Processor over SSL Vision (legacy) due to shifting color calibrations throughout +the day. + +You may wish to provide additional lighting for consistency. A lighting guide is coming soon to the field documentation. + +## Power + +Power delivery is inherent to the venue, and thus must be provided by event organizer/venue. All league equipment +supports international power standards 100-240V, 50-60Hz. Teams are also expected to bring equipment that is +internationally compatible. This section outlines the power requirements. It's worth checking venue pricing to supply +extra outlets. In many cases it's cheaper to buy local power strips wholesale and only have the venue provide +approximate single outlet/plug drops with the organizer or teams providing power strips/extensions. + +The event organizer only needs to provide 1 ~1400W drop per team table cluster. That figure is a circuit rating, not a +continuous load. It's sized so a team can run a hot air rework station while repairing a robot, which any one team does +for a few minutes at a time. The practical effect is that each team needs the equivalent of one standard 120V/20A booth +circuit, or 230V/16A outside North America. At 230V two teams will share a single 16A circuit, which is worth pointing +out to the venue since it halves the circuit count. + +Event organizers should plan for an average power usage of 750W per team (this is conservative) and should include +outlets for the needed field equipment. When sizing the feeder, ask the venue's electrician to apply a normal receptacle +demand factor rather than summing the per team circuit ratings. Total field power draw in its full configuration will +not exceed ~1.2kW, excluding any streaming PCs. + +## Network + +Organizers should be prepared to provide field networks. This means either providing a router per field for isolation, +or setting up VLANs. Most teams simply reuse their lab equipment for regional events. For larger events, a networking +contractor may be required and the [network documentation](../field/network/compnetwork.md) may offer some help. PoE +will need to be provided for any field running remote controls. + +Internet at the field is not guaranteed to participants, but is a strong positive if it can be included. + +One network drop should be provided per team table, or WiFi can be provided if you have control over the channel +allocation. diff --git a/docs-source/field/network/compnetwork.md b/docs-source/field/network/compnetwork.md index 4e7c62b..f3c0aeb 100644 --- a/docs-source/field/network/compnetwork.md +++ b/docs-source/field/network/compnetwork.md @@ -193,8 +193,8 @@ at least a /24 subnet, with /16 preferred. For an example network, 10.10.0.0/16: - Subnet address CIDR notation: `10.10.0.0/16` - Gateway: 10.10.0.1 -- Reserved for management addressing: `10.10.0.2`-`10.10.0.5` -- DHCP range: `10.10.0.6`-`10.10.255.249` +- Reserved for management addressing: `10.10.0.2`-`10.10.0.50` +- DHCP range: `10.10.0.51`-`10.10.255.249` - Broadcast address: `10.10.255.255` [CIDR standardized notation and allocations](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing) diff --git a/docs-source/index.md b/docs-source/index.md index 70ef1fe..bbc8e67 100644 --- a/docs-source/index.md +++ b/docs-source/index.md @@ -9,6 +9,8 @@ common to the league as a whole. - [Field Technical Documentation](field.md) - [Field Camera Calculator](field/camera-calculator.md) - [Protocol/Coms Technical Documentation](protocol.md) +- [Events and Hosting Documentation](events.md) +- [Venue Requirements Calculator](events/venue-calculator.md) ## Motivation @@ -22,4 +24,5 @@ caption: Contents --- field protocol +events ```