Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ This repository publishes the NILMTK start page at <https://nilmtk.github.io/>.
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.
Expand All @@ -22,7 +31,7 @@ Then open <http://localhost:8000/>.
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.
Expand Down
1 change: 1 addition & 0 deletions css/landing-page.css
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
16 changes: 8 additions & 8 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ <h2>Which repository<br>should I use?</h2>
<h3>NILMTK</h3>
</div>
<div class="repo-purpose">
<strong>Use this for dataset and meter work.</strong>
<p>Converters, buildings, meters, preprocessing, metrics, and classical baselines.</p>
<strong>Dataset conversion, meter access, preprocessing, and metrics.</strong>
<p>Converters, buildings, meters, time windows, metrics, and classical reference methods.</p>
</div>
<div class="repo-links">
<a href="https://github.com/nilmtk/nilmtk">Repository <span aria-hidden="true">↗</span></a>
Expand All @@ -119,8 +119,8 @@ <h3>NILMTK</h3>
<h3>NILM Metadata</h3>
</div>
<div class="repo-purpose">
<strong>Use this for shared dataset semantics.</strong>
<p>Appliance taxonomy, synonyms, meter relationships, and dataset schema.</p>
<strong>Appliance taxonomy, synonyms, meter relationships, and dataset schema.</strong>
<p>Controlled vocabulary and structured metadata shared by the other repositories.</p>
</div>
<div class="repo-links">
<a href="https://github.com/nilmtk/nilm_metadata">Repository <span aria-hidden="true">↗</span></a>
Expand All @@ -134,8 +134,8 @@ <h3>NILM Metadata</h3>
<h3>nilmtk-contrib</h3>
</div>
<div class="repo-purpose">
<strong>Use this to run or add a model.</strong>
<p>Maintained classical, TensorFlow, and PyTorch implementations with shared interfaces.</p>
<strong>Disaggregation model implementation and testing.</strong>
<p>Classical, TensorFlow, and PyTorch implementations with shared interfaces.</p>
</div>
<div class="repo-links">
<a href="https://github.com/nilmtk/nilmtk-contrib">Repository <span aria-hidden="true">↗</span></a>
Expand All @@ -149,8 +149,8 @@ <h3>nilmtk-contrib</h3>
<h3>NILMbench</h3>
</div>
<div class="repo-purpose">
<strong>Use this for comparable experiments.</strong>
<p>T1/T2/T3 protocols, CPU smoke tests, CUDA runs, result provenance, and the leaderboard.</p>
<strong>Fixed T1/T2/T3 evaluation and published result bundles.</strong>
<p>CPU smoke tests, CUDA runs, protocol validation, provenance, and the leaderboard.</p>
</div>
<div class="repo-links">
<a href="https://github.com/nilmtk/nilmbench">Repository <span aria-hidden="true">↗</span></a>
Expand Down
244 changes: 244 additions & 0 deletions scripts/audit_ui.py
Original file line number Diff line number Diff line change
@@ -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())
15 changes: 15 additions & 0 deletions scripts/check_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,",
Expand Down Expand Up @@ -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] = []
Expand Down Expand Up @@ -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}")
Expand Down