diff --git a/README.md b/README.md
index fed0888..0c89a00 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,15 @@ This repository publishes the NILMTK start page at .
It answers four questions: which repository to use, how to install it, which
paper to cite, and how a model reaches the benchmark leaderboard.
+## Ecosystem repositories
+
+| Research task | Repository |
+| --- | --- |
+| Dataset conversion, meter access, preprocessing, and metrics | [NILMTK core](https://github.com/nilmtk/nilmtk) |
+| Appliance taxonomy, synonyms, meter relationships, and dataset schema | [NILM Metadata](https://github.com/nilmtk/nilm_metadata) |
+| Disaggregation model implementation and testing | [nilmtk-contrib](https://github.com/nilmtk/nilmtk-contrib) |
+| Fixed T1/T2/T3 evaluation and published result bundles | [NILMbench](https://github.com/nilmtk/nilmbench) |
+
The site is deliberately dependency-free: semantic HTML, one stylesheet, and a
small progressive-enhancement script. Existing generated core API documentation
under `nilmtk/` remains separate from the landing page.
@@ -22,7 +31,7 @@ Then open .
python3 scripts/check_site.py
```
-The check rejects duplicate IDs, broken local assets and fragment links,
+The check rejects divergent repository responsibilities, duplicate IDs, broken local assets and fragment links,
missing tab panels, insecure public URLs, missing canonical repository or
citation links, promotional copy that was intentionally removed, and accidental
reintroduction of the retired Bootstrap/jQuery stack.
diff --git a/css/landing-page.css b/css/landing-page.css
index 0deea6e..a61fdb8 100644
--- a/css/landing-page.css
+++ b/css/landing-page.css
@@ -564,6 +564,7 @@ h3 {
.architecture-copy h2 {
margin-bottom: 28px;
+ font-size: clamp(2.65rem, 4.5vw, 4.25rem);
}
.architecture-copy > p:not(.eyebrow) {
diff --git a/index.html b/index.html
index 35e18a0..666de13 100644
--- a/index.html
+++ b/index.html
@@ -104,8 +104,8 @@
Repository ↗
diff --git a/scripts/audit_ui.py b/scripts/audit_ui.py
new file mode 100644
index 0000000..aec346d
--- /dev/null
+++ b/scripts/audit_ui.py
@@ -0,0 +1,244 @@
+#!/usr/bin/env python3
+"""Exercise public NILMTK pages at desktop, tablet, and phone widths."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+import re
+import sys
+from urllib.parse import urlparse
+
+from playwright.sync_api import Page, sync_playwright
+
+
+VIEWPORTS = {
+ "desktop": {"width": 1440, "height": 1000},
+ "tablet": {"width": 820, "height": 1000},
+ "phone": {"width": 390, "height": 844},
+}
+
+
+def _slug(url: str) -> str:
+ parsed = urlparse(url)
+ value = f"{parsed.netloc}{parsed.path}".strip("/") or "page"
+ return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
+
+
+def _page_audit(page: Page) -> dict:
+ return page.evaluate(
+ """() => {
+ const visible = (element) => {
+ const style = getComputedStyle(element);
+ return style.visibility !== 'hidden' && style.display !== 'none' &&
+ element.getClientRects().length > 0;
+ };
+ const text = (element) => (element.innerText || element.textContent || '').trim();
+ const textEscapes = (element) => {
+ const bounds = element.getBoundingClientRect();
+ const style = getComputedStyle(element);
+ const clipsVertically = ['hidden', 'clip'].includes(style.overflowY);
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
+ while (walker.nextNode()) {
+ if (!walker.currentNode.textContent.trim()) continue;
+ const range = document.createRange();
+ range.selectNodeContents(walker.currentNode);
+ for (const rect of range.getClientRects()) {
+ if (rect.left < bounds.left - 2 || rect.right > bounds.right + 2 ||
+ (clipsVertically &&
+ (rect.top < bounds.top - 4 || rect.bottom > bounds.bottom + 8))) {
+ return true;
+ }
+ }
+ }
+ return false;
+ };
+ const ids = [...document.querySelectorAll('[id]')].map((element) => element.id);
+ const duplicateIds = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))];
+ const clippedText = [...document.querySelectorAll('h1,h2,h3,p,a,button,label,dt,dd,th,td,code,strong,small')]
+ .filter((element) => visible(element) && text(element))
+ .filter((element) => {
+ const style = getComputedStyle(element);
+ const overflowAllowsScroll = ['auto', 'scroll'].includes(style.overflowX) ||
+ ['auto', 'scroll'].includes(style.overflowY);
+ return !overflowAllowsScroll && textEscapes(element);
+ })
+ .map((element) => ({
+ tag: element.tagName,
+ text: text(element).slice(0, 100),
+ client: [element.clientWidth, element.clientHeight],
+ scroll: [element.scrollWidth, element.scrollHeight],
+ }));
+ const unnamedControls = [...document.querySelectorAll('a[href],button,input,select,textarea')]
+ .filter(visible)
+ .filter((element) => {
+ if (element.matches('input[type="hidden"]')) return false;
+ const labelled = element.getAttribute('aria-label') ||
+ element.getAttribute('aria-labelledby') ||
+ (element.id && document.querySelector(`label[for="${CSS.escape(element.id)}"]`));
+ return !labelled && !text(element) && !element.getAttribute('title');
+ })
+ .map((element) => element.outerHTML.slice(0, 160));
+ const headings = [...document.querySelectorAll('h1,h2,h3,h4,h5,h6')]
+ .filter(visible)
+ .map((element) => ({level: Number(element.tagName.slice(1)), text: text(element)}));
+ const skippedHeadingLevels = headings
+ .map((heading, index) => ({heading, previous: headings[index - 1]}))
+ .filter(({heading, previous}) => previous && heading.level > previous.level + 1)
+ .map(({heading, previous}) => `${previous.level}->${heading.level}: ${heading.text}`);
+ const brokenFragments = [...document.querySelectorAll('a[href^="#"]')]
+ .map((link) => link.getAttribute('href'))
+ .filter((href) => href.length > 1 && !document.getElementById(decodeURIComponent(href.slice(1))));
+ return {
+ title: document.title,
+ lang: document.documentElement.lang,
+ h1Count: document.querySelectorAll('h1').length,
+ mainCount: document.querySelectorAll('main').length,
+ horizontalOverflow: document.documentElement.scrollWidth - innerWidth,
+ duplicateIds,
+ clippedText,
+ unnamedControls,
+ skippedHeadingLevels,
+ brokenFragments,
+ missingImageAlt: [...document.querySelectorAll('img:not([alt])')].length,
+ interactiveCount: document.querySelectorAll('a[href],button,input,select,textarea').length,
+ };
+ }"""
+ )
+
+
+def _exercise_interactions(page: Page) -> list[str]:
+ checks: list[str] = []
+ menu = page.locator("[data-menu-button]")
+ if menu.count():
+ menu.click()
+ if menu.get_attribute("aria-expanded") != "true":
+ raise AssertionError("mobile menu did not expose its expanded state")
+ page.keyboard.press("Escape")
+ if menu.get_attribute("aria-expanded") != "false":
+ raise AssertionError("Escape did not close the mobile menu")
+ checks.append("mobile-menu")
+
+ tabs = page.locator('[role="tab"]')
+ if tabs.count() > 1:
+ tabs.first.focus()
+ page.keyboard.press("ArrowRight")
+ if tabs.nth(1).get_attribute("aria-selected") != "true":
+ raise AssertionError("tab keyboard navigation did not change panels")
+ checks.append("keyboard-tabs")
+
+ resolution = page.locator("#filter-resolution")
+ if resolution.count():
+ page.wait_for_selector('#leaderboard-root[aria-busy="false"]')
+ values = resolution.locator("option").evaluate_all(
+ "options => options.map(option => option.value)"
+ )
+ if "900" in values:
+ resolution.select_option("900")
+ page.wait_for_timeout(100)
+ meta = page.locator(".cohort-meta").all_inner_texts()
+ if not meta or any("15 min resolution" not in value for value in meta):
+ raise AssertionError("15-minute filter rendered a mixed-resolution cohort")
+ checks.append("15-minute-filter")
+
+ return checks
+
+
+def _reveal_lazy_content(page: Page) -> None:
+ """Traverse the page so lazy assets and intersection effects are observable."""
+ height = page.evaluate("document.documentElement.scrollHeight")
+ step = max(300, int(page.viewport_size["height"] * 0.7))
+ for position in range(0, height, step):
+ page.evaluate("position => window.scrollTo(0, position)", position)
+ page.wait_for_timeout(20)
+ page.evaluate(
+ "document.querySelectorAll('.fade-up').forEach(element => element.classList.add('visible'))"
+ )
+ page.evaluate("window.scrollTo(0, 0)")
+ page.wait_for_timeout(100)
+
+
+def _assert_clean(result: dict) -> None:
+ audit = result["audit"]
+ failures = []
+ for field in (
+ "duplicateIds",
+ "clippedText",
+ "unnamedControls",
+ "skippedHeadingLevels",
+ "brokenFragments",
+ ):
+ if audit[field]:
+ failures.append(f"{field}={audit[field]!r}")
+ if audit["horizontalOverflow"] > 1:
+ failures.append(f"horizontalOverflow={audit['horizontalOverflow']}")
+ if audit["h1Count"] != 1:
+ failures.append(f"h1Count={audit['h1Count']}")
+ if audit["mainCount"] != 1:
+ failures.append(f"mainCount={audit['mainCount']}")
+ if not audit["lang"]:
+ failures.append("missing document language")
+ if audit["missingImageAlt"]:
+ failures.append(f"missingImageAlt={audit['missingImageAlt']}")
+ if failures:
+ raise AssertionError(
+ f"{result['url']} at {result['viewport']}: " + "; ".join(failures)
+ )
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("urls", nargs="+")
+ parser.add_argument("--screenshots", type=Path, default=Path("ui-audit"))
+ parser.add_argument(
+ "--chrome",
+ default="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ )
+ args = parser.parse_args()
+ args.screenshots.mkdir(parents=True, exist_ok=True)
+ reports = []
+ try:
+ with sync_playwright() as playwright:
+ browser = playwright.chromium.launch(
+ executable_path=args.chrome,
+ headless=True,
+ args=["--allow-file-access-from-files"],
+ )
+ for url in args.urls:
+ for viewport_name, viewport in VIEWPORTS.items():
+ page = browser.new_page(viewport=viewport)
+ response = page.goto(url, wait_until="networkidle")
+ is_local_file = urlparse(url).scheme == "file"
+ if (response is None and not is_local_file) or (
+ response is not None and not response.ok
+ ):
+ status = None if response is None else response.status
+ raise AssertionError(f"{url} returned HTTP {status}")
+ page.wait_for_timeout(150)
+ _reveal_lazy_content(page)
+ interactions = (
+ _exercise_interactions(page) if viewport_name == "phone" else []
+ )
+ screenshot = args.screenshots / f"{_slug(url)}-{viewport_name}.png"
+ page.screenshot(path=screenshot, full_page=True, animations="disabled")
+ result = {
+ "url": url,
+ "viewport": viewport_name,
+ "audit": _page_audit(page),
+ "interactions": interactions,
+ "screenshot": str(screenshot),
+ }
+ _assert_clean(result)
+ reports.append(result)
+ page.close()
+ browser.close()
+ except Exception as exc:
+ print(json.dumps({"status": "failed", "error": str(exc)}, indent=2))
+ return 1
+ print(json.dumps({"status": "passed", "reports": reports}, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/check_site.py b/scripts/check_site.py
index 414c1b8..a125e6b 100644
--- a/scripts/check_site.py
+++ b/scripts/check_site.py
@@ -12,6 +12,14 @@
ROOT = Path(__file__).resolve().parents[1]
INDEX = ROOT / "index.html"
+README = ROOT / "README.md"
+
+REPOSITORY_RESPONSIBILITIES = (
+ "Dataset conversion, meter access, preprocessing, and metrics",
+ "Appliance taxonomy, synonyms, meter relationships, and dataset schema",
+ "Disaggregation model implementation and testing",
+ "Fixed T1/T2/T3 evaluation and published result bundles",
+)
REQUIRED_URLS = {
"https://github.com/nilmtk/nilmtk",
@@ -30,6 +38,7 @@
"I want to run a model",
"I need comparable results",
"I need names and schema",
+ *(f"{responsibility}." for responsibility in REPOSITORY_RESPONSIBILITIES),
"Python 3.11 + uv.",
"What should I cite?",
"Add a model,",
@@ -78,6 +87,7 @@ def local_target(target: str) -> Path | None:
def main() -> int:
source = INDEX.read_text(encoding="utf-8")
+ readme = README.read_text(encoding="utf-8")
parser = SiteParser()
parser.feed(source)
errors: list[str] = []
@@ -109,6 +119,11 @@ def main() -> int:
for text in REQUIRED_TEXT:
if text not in source:
errors.append(f"missing task-first onboarding copy: {text}")
+ for responsibility in REPOSITORY_RESPONSIBILITIES:
+ if responsibility not in readme:
+ errors.append(
+ f"README.md is missing repository responsibility: {responsibility}"
+ )
for text in RETIRED_COPY:
if text in source:
errors.append(f"retired promotional copy returned: {text}")