In The Game
  • Home
  • World Cup
  • Blog
  • Games
  • AFL
    • Overview

    • Matches
    • Ladder

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck
    • Age Curves

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • Football
    • Overview

    • Matches
    • Leagues

    • World Cup 2026
    • Simulator
    • Wall Chart
    • Pick Your Bracket
    • Title Race
    • Group Projections
    • Match Predictions
    • Player Stats
    • Player Ratings
    • Venues
    • Team Strength
    • Head to Head

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • About

AFL Team Ratings

Skip to content

AFL > Team Ratings

AFL · The TORP Project · Squad Strength

Which AFL teams are best built?

Team TORP aggregates every player’s rating weighted by predicted time-on-ground. The result: a single ranking of squad depth — minutes-aware, position-balanced, and centred so the 18 clubs sum to zero around the league average.

Show code
statsEsc = window.statsEsc
statsTable = window.statsTable

fetchParquet = window.fetchParquet
base_url = window.DATA_BASE_URL

predToFull = window.aflTeamMaps?.predToFull || {}
Show code
ratings = {
  try { return await fetchParquet(base_url + "afl/ratings.parquet") }
  catch (e) { console.error("[team-ratings] ratings load failed:", e); return null }
}

// Start skills fetch in background (for TOG weights + stat category toggles)
{ window._teamSkillsPromise = fetchParquet(base_url + "afl/player-skills.parquet").catch(e => { console.warn("[team-ratings] skills load failed:", e); return null }) }
Show code
// ── Byline strip ─────────────────────────────────────────────
html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>Daily 02:00 AEST</strong></span>
  <span><a href="../blog/2026-04-24-understanding-torp/">Methodology &darr;</a></span>
  <span><a href="definitions.html">Definitions &nearr;</a></span>
  <span>&approx; 4 min read</span>
</div>`
Show code
// ── Sidebar collapse toggle ─────────────────────────────────
window.editorial.sidebarToggle()
Show code
// Stat category toggle — same as player-ratings
viewof teamRatingCategory = {
  const options = ["Value", "Scoring", "Possession", "Contested", "Midfield", "Defense", "Ruck"]
  const _key = "_statCategory_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = window[_key] || "Value"
  const container = html`<div class="pos-pills">
    ${options.map(o => `<button class="pos-pill ${o === _saved ? 'active' : ''}" data-val="${o}">${o}</button>`).join('')}
  </div>`
  container.value = _saved
  container.querySelectorAll('.pos-pill').forEach(btn => {
    btn.addEventListener('click', () => {
      container.querySelectorAll('.pos-pill').forEach(b => b.classList.remove('active'))
      btn.classList.add('active')
      container.value = btn.dataset.val
      window[_key] = container.value
      container.dispatchEvent(new Event('input', {bubbles: true}))
    })
  })
  return container
}
Show code
_buildTogWeights = async function() {
  const skills = await window._teamSkillsPromise
  if (!skills) return new Map()
  const m = new Map()
  for (const s of skills) {
    m.set(s.player_id, (s.squad_selection_rating || 0) * (s.cond_tog_rating || 0))
  }
  return m
}

// Aggregate ratings per team, weighted by TOG
teamRatings = {
  if (!ratings) return null

  const togWeights = await _buildTogWeights()
  const valueCols = ["torp", "epr", "recv_epr", "disp_epr", "spoil_epr", "hitout_epr", "psr", "osr", "dsr"]
  const TARGET_WEIGHT = 18 // normalize TOG weights to 18 on-field players

  // Filter to current season only
  let currentSeason = -Infinity; for (const r of ratings) { if (r.season > currentSeason) currentSeason = r.season }
  const seasonRatings = ratings.filter(r => r.season === currentSeason)

  const latestPlayers = window.deduplicateLatest(seasonRatings)

  const byTeam = new Map()
  for (const r of latestPlayers) {
    const fullName = predToFull[r.team] || r.team
    if (!byTeam.has(fullName)) byTeam.set(fullName, [])
    byTeam.get(fullName).push(r)
  }

  const teams = []
  byTeam.forEach((players, team) => {
    // Normalize TOG weights to sum to TARGET_WEIGHT
    let rawWeightSum = 0
    for (const p of players) rawWeightSum += togWeights.get(p.player_id) || 0
    const scale = rawWeightSum > 0 ? TARGET_WEIGHT / rawWeightSum : 0

    const row = { team }
    for (const col of valueCols) {
      let weightedSum = 0
      for (const p of players) {
        const w = (togWeights.get(p.player_id) || 0) * scale
        weightedSum += (p[col] || 0) * w
      }
      row[col] = weightedSum
    }
    // Best player by TORP
    const best = players.reduce((a, b) => (a.torp || 0) > (b.torp || 0) ? a : b, players[0])
    row.top_player = best?.player_name || ""
    teams.push(row)
  })

  const avgs = {}
  for (const col of valueCols) {
    const sum = teams.reduce((s, t) => s + t[col], 0)
    avgs[col] = sum / teams.length
  }
  for (const t of teams) {
    for (const col of valueCols) t[col] = +(t[col] - avgs[col]).toFixed(1)
  }

  teams.sort((a, b) => b.torp - a.torp)
  return teams.map((t, i) => ({ rank: i + 1, ...t }))
}
Show code
// Aggregate per-stat ratings by team (for stat category toggles)
teamStatRatings = {
  if (teamRatingCategory === "Value") return null
  const skills = await window._teamSkillsPromise
  if (!skills || !ratings) return null

  const defs = window.aflStatDefs || {}
  const catKey = teamRatingCategory.toLowerCase()
  const catDef = defs[catKey]
  if (!catDef) return null

  const cols = Object.keys(skills[0] || {})
  const suffix = cols.some(c => c.endsWith("_rating") && c !== "cond_tog_rating" && c !== "squad_selection_rating" && c !== "rating_points_rating") ? "_rating" : "_skill"

  const TARGET_WEIGHT = 18
  const skillsMap = new Map()
  const togWeights = new Map()
  for (const s of skills) {
    skillsMap.set(s.player_id, s)
    togWeights.set(s.player_id, (s.squad_selection_rating || 0) * (s.cond_tog_rating || 0))
  }

  // Filter to current season only
  let currentSeason = -Infinity; for (const r of ratings) { if (r.season > currentSeason) currentSeason = r.season }
  const seasonRatings = ratings.filter(r => r.season === currentSeason)

  const latestByPlayer2 = new Map()
  for (const r of seasonRatings) {
    const existing = latestByPlayer2.get(r.player_id)
    if (!existing || (r.round || 0) > (existing.round || 0)) {
      latestByPlayer2.set(r.player_id, r)
    }
  }

  const byTeam = new Map()
  for (const r of latestByPlayer2.values()) {
    const fullName = predToFull[r.team] || r.team
    if (!byTeam.has(fullName)) byTeam.set(fullName, [])
    byTeam.get(fullName).push(r)
  }

  const statCols = catDef.columns
  const teams = []

  byTeam.forEach((players, team) => {
    // Normalize TOG weights to sum to TARGET_WEIGHT
    let rawWeightSum = 0
    for (const p of players) rawWeightSum += togWeights.get(p.player_id) || 0
    const scale = rawWeightSum > 0 ? TARGET_WEIGHT / rawWeightSum : 0

    const row = { team }
    for (const col of statCols) {
      let weightedSum = 0
      for (const p of players) {
        const sk = skillsMap.get(p.player_id)
        if (sk && sk[col + suffix] != null) {
          const w = (togWeights.get(p.player_id) || 0) * scale
          weightedSum += sk[col + suffix] * w
        }
      }
      row[col] = +weightedSum.toFixed(2)
    }
    teams.push(row)
  })

  const sortCol = statCols[0]
  teams.sort((a, b) => (b[sortCol] || 0) - (a[sortCol] || 0))
  return teams.map((t, i) => ({ rank: i + 1, ...t }))
}
Show code
// ── View toggle (Table / Scatter) ───────────────────────────
{
  const _key = "_viewMode_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  if (!window[_key]) window[_key] = "Table"
  const container = document.createElement("div")
  container.className = "pos-pills"
  for (const label of ["Table", "Scatter"]) {
    const btn = document.createElement("button")
    btn.className = "pos-pill" + (label === window[_key] ? " active" : "")
    btn.textContent = label
    btn.addEventListener("click", () => {
      container.querySelectorAll(".pos-pill").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      window[_key] = label
      const isTable = label === "Table"
      const tableView = document.querySelector(".team-ratings-table-view")
      const scatterView = document.querySelector(".team-ratings-scatter-view")
      if (tableView) tableView.style.display = isTable ? "" : "none"
      if (scatterView) scatterView.style.display = isTable ? "none" : ""
    })
    container.appendChild(btn)
  }
  return container
}
Show code
viewof teamSearch = {
  const data = teamRatingCategory === "Value" ? teamRatings : teamStatRatings
  if (!data) return html``
  return Inputs.search(data, { placeholder: "Search teams…" })
}
Show code
// ── Scatter plot (always renders, starts hidden) ─────────────
{
  const isValue = teamRatingCategory === "Value"
  const data = isValue ? teamRatings : teamStatRatings

  if (!data || data.length === 0) return html``

  const defs = window.aflStatDefs || {}
  let metricOpts
  if (isValue) {
    metricOpts = [
      { value: "torp", label: "TORP" }, { value: "epr", label: "EPR" },
      { value: "psr", label: "PSR" }, { value: "osr", label: "OSR" }, { value: "dsr", label: "DSR" }
    ]
  } else {
    const catKey = teamRatingCategory.toLowerCase()
    const catDef = defs[catKey]
    const statCols = catDef ? catDef.columns.filter(c => data[0] && data[0][c] !== undefined) : []
    metricOpts = statCols.map(c => ({ value: c, label: catDef.header[c] || c }))
  }

  if (metricOpts.length === 0) return html``

  const defaultX = metricOpts.find(m => m.value === "osr" || m.value === "epr")?.value || metricOpts[0]?.value
  const defaultY = metricOpts.find(m => m.value === "dsr" || m.value === "psr")?.value || (metricOpts[1]?.value || metricOpts[0]?.value)

  const headerSrc = Object.fromEntries(metricOpts.map(m => [m.value, m.label]))

  const wrapper = document.createElement("div")
  wrapper.className = "team-ratings-scatter-view"
  wrapper.style.display = window["_viewMode_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")] === "Scatter" ? "" : "none"

  const axisBar = document.createElement("div")
  axisBar.className = "scatter-axis-bar"

  const xLabel = document.createElement("label")
  xLabel.textContent = "X: "
  const xSel = document.createElement("select")
  for (const opt of metricOpts) {
    const o = document.createElement("option"); o.value = opt.value; o.textContent = opt.label; xSel.appendChild(o)
  }
  xSel.value = defaultX
  xLabel.appendChild(xSel)

  const yLabel = document.createElement("label")
  yLabel.textContent = "Y: "
  const ySel = document.createElement("select")
  for (const opt of metricOpts) {
    const o = document.createElement("option"); o.value = opt.value; o.textContent = opt.label; ySel.appendChild(o)
  }
  ySel.value = defaultY
  yLabel.appendChild(ySel)

  axisBar.appendChild(xLabel)
  axisBar.appendChild(yLabel)
  wrapper.appendChild(axisBar)

  const chartDiv = document.createElement("div")
  wrapper.appendChild(chartDiv)

  function drawChart(xCol, yCol) {
    while (chartDiv.firstChild) chartDiv.removeChild(chartDiv.firstChild)
    window.chartHelpers.drawScatterPlot(chartDiv, {
      data,
      xCol, yCol,
      xLabel: headerSrc[xCol] || xCol,
      yLabel: headerSrc[yCol] || yCol,
      labelCol: "team",
      format: { [xCol]: v => Number(v).toFixed(1), [yCol]: v => Number(v).toFixed(1) },
      hrefFn: (row) => `team.html#team=${encodeURIComponent(row.team)}`,
      imageFn: (row) => window.aflTeamMaps?.teamLogo(row.team) || null,
      tooltipFn: (tip, row, xC, yC, xL, yL, f) => {
        const header = document.createElement("div")
        header.className = "scatter-tip-header"
        const logo = window.aflTeamMaps.teamLogo(row.team)
        if (logo) {
          const badge = document.createElement("img")
          badge.className = "scatter-tip-headshot"
          badge.src = logo
          badge.alt = ""
          header.appendChild(badge)
        }
        const info = document.createElement("div")
        const nameEl = document.createElement("div")
        nameEl.className = "scatter-tip-name"
        nameEl.textContent = row.team || ""
        info.appendChild(nameEl)
        header.appendChild(info)
        tip.appendChild(header)
        const fX = f[xC] ? f[xC](row[xC]) : Number(row[xC]).toFixed(1)
        const fY = f[yC] ? f[yC](row[yC]) : Number(row[yC]).toFixed(1)
        window.chartHelpers.buildFieldTooltip(tip, "", [[xL, fX], [yL, fY]], true)
        const title = tip.querySelector(".ft-title")
        if (title && !title.textContent) title.remove()
      }
    })
  }

  drawChart(defaultX, defaultY)

  xSel.addEventListener("change", () => drawChart(xSel.value, ySel.value))
  ySel.addEventListener("change", () => drawChart(xSel.value, ySel.value))

  return wrapper
}
Show code
{
  const isValue = teamRatingCategory === "Value"
  const data = isValue ? teamRatings : teamStatRatings

  if (!data || data.length === 0) {
    if (!isValue) return html`<p class="text-muted">Loading stat ratings...</p>`
    return html`<p class="text-muted">No team ratings data available.</p>`
  }

  const teamLink = window.aflTeamMaps?.renderTeamCell || ((v) => `<strong>${statsEsc(v)}</strong>`)
  const defs = window.aflStatDefs || {}

  if (isValue) {
    // Same columns as player Value tab: TORP, EPR components, PSR components
    const tableEl = statsTable(teamSearch, {
      columns: ["rank", "team", "torp", "epr", "recv_epr", "disp_epr", "spoil_epr", "hitout_epr", "psr", "osr", "dsr", "top_player"],
      header: { rank: "#", team: "Team", torp: "TORP", epr: "EPR", recv_epr: "Reception", disp_epr: "Disposal", spoil_epr: "Spoil", hitout_epr: "Hitout", psr: "PSR", osr: "OSR", dsr: "DSR", top_player: "Best Player" },
      groups: [{ label: "", span: 2 }, { label: "EPR (vs avg)", span: 6 }, { label: "PSR (vs avg)", span: 3 }, { label: "", span: 1 }],
      format: Object.fromEntries(["torp","epr","recv_epr","disp_epr","spoil_epr","hitout_epr","psr","osr","dsr"].map(c => [c, x => x != null ? (x > 0 ? "+" : "") + x.toFixed(1) : ""])),
      tooltip: { torp: "Total Overall Rating of Players — blended EPR and PSR", epr: "Expected Possession Rating — predictive career rating from EPV", recv_epr: "Reception EPR — predictive rating for receiving", disp_epr: "Disposal EPR — predictive rating for kicks and handballs", spoil_epr: "Spoil EPR — predictive rating for defensive acts", hitout_epr: "Hitout EPR — predictive rating for ruck hitouts", psr: "Player Stat Rating — box-score regularised plus-minus", osr: "Offensive Stat Rating — offensive component of PSR", dsr: "Defensive Stat Rating — defensive component of PSR" },
      heatmap: { torp: "high-good", epr: "high-good", recv_epr: "diverging", disp_epr: "diverging", spoil_epr: "diverging", hitout_epr: "diverging", psr: "diverging", osr: "diverging", dsr: "diverging" },
      render: {
        team: teamLink,
        top_player: (v) => v ? `<span class="player-link">${statsEsc(v)}</span>` : ""
      },
      sort: "torp", reverse: true, rows: 20
    })
    const wrap = document.createElement("div")
    wrap.className = "team-ratings-table-view"
    wrap.style.display = window["_viewMode_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")] === "Table" ? "" : "none"
    wrap.appendChild(tableEl)
    return wrap
  }

  // Stat category view
  const catKey = teamRatingCategory.toLowerCase()
  const catDef = defs[catKey]
  if (!catDef) return html`<p class="text-muted">Category not found.</p>`

  const statCols = catDef.columns.filter(c => data[0] && data[0][c] !== undefined)
  const heatmap = {}
  for (const c of statCols) heatmap[c] = catDef.heatmap?.[c] || "high-good"

  const tableEl2 = statsTable(teamSearch, {
    columns: ["rank", "team", ...statCols],
    header: { rank: "#", team: "Team", ...catDef.header },
    groups: [{ label: "", span: 2 }, { label: teamRatingCategory + " Ratings (TOG-weighted avg)", span: statCols.length }],
    format: Object.fromEntries(statCols.map(c => [c, x => x?.toFixed(2) ?? ""])),
    tooltip: catDef.tooltip || {},
    heatmap,
    render: { team: teamLink },
    sort: statCols[0] || "rank", reverse: true, rows: 20
  })
  const wrap2 = document.createElement("div")
  wrap2.className = "team-ratings-table-view"
  wrap2.style.display = window["_viewMode_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")] === "Table" ? "" : "none"
  wrap2.appendChild(tableEl2)
  return wrap2
}
Show code
// ── Source attribution row ──────────────────────────────────
{
  const src = document.createElement("div"); src.className = "table-source"
  const left = document.createElement("span")
  left.appendChild(document.createTextNode("Source: "))
  const a = document.createElement("a")
  a.href = "https://github.com/peteowen1/torpdata"; a.target = "_blank"; a.rel = "noopener"
  a.textContent = "torpdata"
  left.appendChild(a)
  left.appendChild(document.createTextNode(" · Aggregated from player ratings × TOG weights · CC BY 4.0"))
  const right = document.createElement("span")
  right.textContent = "Latest season, all 18 clubs · Centred at league average"
  src.appendChild(left); src.appendChild(right)
  return src
}
Show code
// ── Editorial side rail ─────────────────────────────────────
{
  const inner = document.createElement("div")
  inner.className = "side-rail-inner"

  const { railBlock, btnTile } = window.editorial

  if (!teamRatings || teamRatings.length === 0) {
    const lb = railBlock("Loading")
    const p = document.createElement("p")
    p.style.cssText = "color: var(--site-muted-color); font-size: 0.85rem; font-family: 'Source Serif 4', Georgia, serif; margin: 0;"
    p.textContent = "Resolving team ratings…"
    lb.appendChild(p); inner.appendChild(lb); return inner
  }

  const sorted = [...teamRatings].sort((a, b) => b.torp - a.torp)
  const topTeam = sorted[0]
  const bottomTeam = sorted[sorted.length - 1]
  const spread = topTeam && bottomTeam ? (topTeam.torp - bottomTeam.torp).toFixed(1) : "—"
  const topPlayer = topTeam?.top_player || ""

  // Last updated stamp
  const upd = railBlock("Last Updated")
  const stamp = document.createElement("div"); stamp.className = "update-stamp"
  stamp.textContent = "Daily 02:00 AEST"
  upd.appendChild(stamp)
  const updP = document.createElement("p")
  updP.style.cssText = "font-family: 'Source Serif 4', Georgia, serif; font-size: 0.85rem; color: var(--site-muted-color); margin: 0.7rem 0 0; line-height: 1.55;"
  updP.appendChild(document.createTextNode("Refreshes after every round via the "))
  const code = document.createElement("code")
  code.style.cssText = "font-family: 'JetBrains Mono', monospace; font-size: 0.85em; color: var(--site-body-color)"
  code.textContent = "torpdata"
  updP.appendChild(code)
  updP.appendChild(document.createTextNode(" pipeline."))
  upd.appendChild(updP); inner.appendChild(upd)

  // By The Numbers
  const btn = railBlock("By the Numbers")
  const grid = document.createElement("div"); grid.className = "btn-block"
  grid.appendChild(btnTile("+" + topTeam.torp.toFixed(1), [
    { text: "Highest-rated team", bold: true },
    { text: " · " + topTeam.team },
    ...(topPlayer ? [{ br: true }, { text: "Top player: " + topPlayer }] : [])
  ]))
  grid.appendChild(btnTile(bottomTeam.torp.toFixed(1), [
    { text: "Lowest-rated team", bold: true },
    { text: " · " + bottomTeam.team }
  ]))
  grid.appendChild(btnTile(spread, [
    { text: "League spread", bold: true },
    { text: " · top to bottom, in TORP per game" }
  ]))
  grid.appendChild(btnTile(String(teamRatings.length), [
    { text: "Teams rated", bold: true },
    { text: " · entire AFL competition" }
  ]))
  btn.appendChild(grid); inner.appendChild(btn)

  // About
  const about = railBlock("About Team TORP"); about.classList.add("about-block")
  const p1 = document.createElement("p")
  p1.appendChild(document.createTextNode("Each player's TORP is multiplied by their predicted Time-on-Ground weight, then summed by team and centred at the league average."))
  about.appendChild(p1)
  const p2 = document.createElement("p")
  p2.appendChild(document.createTextNode("Toggle the category pills to see the same aggregation for "))
  const s = document.createElement("strong"); s.textContent = "Scoring / Possession / Contested / Midfield / Defense / Ruck"; p2.appendChild(s)
  p2.appendChild(document.createTextNode(" component ratings."))
  about.appendChild(p2)
  const p3 = document.createElement("p")
  p3.appendChild(document.createTextNode("Open source: "))
  const a = document.createElement("a")
  a.href = "https://github.com/peteowen1/torp"; a.target = "_blank"; a.rel = "noopener"
  a.textContent = "torp"
  p3.appendChild(a); p3.appendChild(document.createTextNode(" on GitHub."))
  about.appendChild(p3); inner.appendChild(about)

  // Read Next
  const read = railBlock("Read Next")
  const ul = document.createElement("ul"); ul.className = "rail-list"
  const links = [
    { href: "ladder.html", title: "Ladder & Sims", meta: "Where each team is projected to finish" },
    { href: "team-stats.html", title: "Team Stats", meta: "Aggregated match statistics" },
    { href: "player-ratings.html", title: "Player Ratings", meta: "Player-level TORP" },
    { href: "../blog/2026-04-24-understanding-torp/", title: "Understanding TORP", meta: "Blog · Methodology" }
  ]
  for (const l of links) {
    const li = document.createElement("li")
    const ax = document.createElement("a")
    ax.href = l.href; ax.textContent = l.title
    const meta = document.createElement("span"); meta.className = "rail-meta"; meta.textContent = l.meta
    ax.appendChild(meta); li.appendChild(ax); ul.appendChild(li)
  }
  read.appendChild(ul); inner.appendChild(read)

  return inner
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer