-
Notifications
You must be signed in to change notification settings - Fork 0
SHA-20: PROFILE scopes CORE/DOMAIN/FULL with gen field catalog #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,220 @@ | ||
| """Extract native field and enum tables from Profile.xlsx for PROFILE validation. | ||
|
|
||
| Metadata flow (design doc §3.1 O1): Profile.xlsx → gen artifacts → validation. | ||
| Runtime validation imports the generated module under ``fit_tool.profile`` and | ||
| never loads openpyxl. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections.abc import Iterable | ||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| from fit_tool.base_type import BaseType | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class FieldSpec: | ||
| """One main (non-subfield) Profile field.""" | ||
|
|
||
| message_name: str | ||
| message_number: int | ||
| field_id: int | ||
| field_name: str | ||
| base_type: BaseType | ||
| type_name: str | ||
| units: str | ||
| scale: float | ||
| offset: float | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class EnumTypeSpec: | ||
| """Closed enum type from the Profile Types sheet (base type ENUM only).""" | ||
|
|
||
| type_name: str | ||
| values: frozenset[int] | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class FieldCatalogData: | ||
| """Full native-field + closed-enum catalog for a Profile version.""" | ||
|
|
||
| fields: tuple[FieldSpec, ...] | ||
| enum_types: tuple[EnumTypeSpec, ...] | ||
|
|
||
|
|
||
| def load_field_catalog_from_profile(profile: Any) -> FieldCatalogData: | ||
| """Build catalog data from a loaded :class:`~fit_tool.gen.profile.Profile`.""" | ||
| fields: list[FieldSpec] = [] | ||
| for message in sorted(profile.messages_by_id.values(), key=lambda m: int(m.id)): | ||
| for field in sorted( | ||
| message.get_fields(), | ||
| key=lambda f: int(f.field_id), | ||
| ): | ||
| type_name = field.type_name or '' | ||
| units = field.units or '' | ||
| scale = float(field.scale) if field.scale is not None else 1.0 | ||
| offset = float(field.offset) if field.offset is not None else 0.0 | ||
| fields.append( | ||
| FieldSpec( | ||
| message_name=str(message.name), | ||
| message_number=int(message.id), | ||
| field_id=int(field.field_id), | ||
| field_name=str(field.name), | ||
| base_type=field.base_type, | ||
| type_name=str(type_name), | ||
| units=str(units), | ||
| scale=scale, | ||
| offset=offset, | ||
| ) | ||
| ) | ||
|
|
||
| # ENUM base types that are ranges / bitfields, not closed value sets. | ||
| # Example: activity_class uses LEVEL mask 0x7f and ATHLETE flag 0x80. | ||
| _OPEN_OR_RANGED_ENUM_TYPES = frozenset({ | ||
| 'activity_class', | ||
| }) | ||
|
|
||
| enum_types: list[EnumTypeSpec] = [] | ||
| for type_name in sorted(profile.types_by_name.keys()): | ||
| type_ = profile.types_by_name[type_name] | ||
| if type_.base_type is not BaseType.ENUM: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When FULL scope validates Useful? React with 👍 / 👎. |
||
| continue | ||
| if type_name in _OPEN_OR_RANGED_ENUM_TYPES: | ||
| continue | ||
| values = getattr(type_, 'values_by_name', None) or {} | ||
| if not values: | ||
| continue | ||
| enum_types.append( | ||
| EnumTypeSpec( | ||
| type_name=str(type_name), | ||
| values=frozenset(int(v) for v in values.values()), | ||
| ) | ||
| ) | ||
|
|
||
| return FieldCatalogData(fields=tuple(fields), enum_types=tuple(enum_types)) | ||
|
|
||
|
|
||
| def load_field_catalog(xlsx_path: str | Path) -> FieldCatalogData: | ||
| """Load field/enum tables from a Garmin Profile spreadsheet.""" | ||
| from fit_tool.gen.profile import Profile | ||
|
|
||
| profile = Profile.load(str(xlsx_path)) | ||
| return load_field_catalog_from_profile(profile) | ||
|
|
||
|
|
||
| def _py_str(value: str) -> str: | ||
| return repr(value) | ||
|
|
||
|
|
||
| def render_field_catalog(data: FieldCatalogData, *, sdk_version: str) -> str: | ||
| """Render a Python module defining PROFILE field/enum tables.""" | ||
| lines: list[str] = [ | ||
| '# Autogenerated. Do not modify.', | ||
| '#', | ||
| f'# Profile: {sdk_version}', | ||
| '"""Native field and closed-enum catalog for PROFILE validation.', | ||
| '', | ||
| 'Generated from the bundled Garmin Profile spreadsheet by ``gen-profile``.', | ||
| '', | ||
| '* ``PROFILE_FIELDS`` — ``(global_message_number, field_id)`` → field metadata', | ||
| '* ``PROFILE_ENUM_VALUES`` — Profile type name → allowed integer values', | ||
| ' (Types sheet entries with base type ``enum`` only)', | ||
| '', | ||
| 'Used by :mod:`fit_tool.validation` DOMAIN/FULL scopes (design doc §3.1 O1).', | ||
| '"""', | ||
| '', | ||
| 'from __future__ import annotations', | ||
| '', | ||
| 'from fit_tool.base_type import BaseType', | ||
| '', | ||
| f'PROFILE_SDK_VERSION = {sdk_version!r}', | ||
| f'PROFILE_MESSAGE_COUNT = {len({f.message_number for f in data.fields})}', | ||
| f'PROFILE_FIELD_COUNT = {len(data.fields)}', | ||
| f'PROFILE_ENUM_TYPE_COUNT = {len(data.enum_types)}', | ||
| '', | ||
| '# (global_message_number, field_id) → ' | ||
| '(name, BaseType, type_name, units, scale, offset)', | ||
| 'PROFILE_FIELDS: dict[tuple[int, int], tuple[str, BaseType, str, str, float, float]] = {', | ||
| ] | ||
|
|
||
| for field in data.fields: | ||
| lines.append( | ||
| f' ({field.message_number}, {field.field_id}): (' | ||
| f'{_py_str(field.field_name)}, ' | ||
| f'BaseType.{field.base_type.name}, ' | ||
| f'{_py_str(field.type_name)}, ' | ||
| f'{_py_str(field.units)}, ' | ||
| f'{field.scale!r}, ' | ||
| f'{field.offset!r}' | ||
| f'), # {field.message_name}' | ||
| ) | ||
|
|
||
| lines.append('}') | ||
| lines.append('') | ||
| lines.append( | ||
| 'PROFILE_FIELD_KEYS: frozenset[tuple[int, int]] = frozenset(PROFILE_FIELDS)' | ||
| ) | ||
| lines.append('') | ||
| lines.append('# Closed enum types only (base type enum). Bitfields / open lists omitted.') | ||
| lines.append('PROFILE_ENUM_VALUES: dict[str, frozenset[int]] = {') | ||
|
|
||
| for enum_type in data.enum_types: | ||
| values_repr = ', '.join(str(v) for v in sorted(enum_type.values)) | ||
| lines.append( | ||
| f' {_py_str(enum_type.type_name)}: frozenset({{{values_repr}}}),' | ||
| ) | ||
|
|
||
| lines.append('}') | ||
| lines.append('') | ||
|
|
||
| # Precompute enum-typed field keys for coverage metrics. | ||
| enum_field_count = sum( | ||
| 1 | ||
| for field in data.fields | ||
| if field.type_name and field.type_name in {e.type_name for e in data.enum_types} | ||
| ) | ||
| lines.append(f'PROFILE_ENUM_FIELD_COUNT = {enum_field_count}') | ||
| lines.append('') | ||
| return '\n'.join(lines) + '\n' | ||
|
|
||
|
|
||
| def write_field_catalog( | ||
| output_path: str | Path, | ||
| *, | ||
| xlsx_path: str | Path, | ||
| sdk_version: str, | ||
| ) -> FieldCatalogData: | ||
| """Extract catalog from *xlsx_path* and write *output_path*.""" | ||
| data = load_field_catalog(xlsx_path) | ||
| text = render_field_catalog(data, sdk_version=sdk_version) | ||
| path = Path(output_path) | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| path.write_text(text, encoding='utf-8') | ||
| return data | ||
|
|
||
|
|
||
| def coverage_summary(data: FieldCatalogData) -> dict[str, Any]: | ||
| """High-level coverage stats for docs / tests.""" | ||
| messages = sorted({f.message_name for f in data.fields}) | ||
| enum_type_names = {e.type_name for e in data.enum_types} | ||
| enum_fields = sum(1 for f in data.fields if f.type_name in enum_type_names) | ||
| return { | ||
| 'messages': len(messages), | ||
| 'fields': len(data.fields), | ||
| 'enum_types': len(data.enum_types), | ||
| 'enum_fields': enum_fields, | ||
| 'message_names': messages, | ||
| } | ||
|
|
||
|
|
||
| def domain_message_field_count( | ||
| data: FieldCatalogData, | ||
| message_numbers: Iterable[int], | ||
| ) -> int: | ||
| """Count main fields belonging to *message_numbers*.""" | ||
| wanted = set(message_numbers) | ||
| return sum(1 for f in data.fields if f.message_number in wanted) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The earlier ranged-enum concern has fresh evidence in
FileType: the generated profile definesMFG_RANGE_MIN = 247andMFG_RANGE_MAX = 254, so intermediate manufacturer-defined file types are valid, yet onlyactivity_classis excluded here and the catalog stores just the endpoints. Under DOMAIN/FULL,_collect_closed_enum_findingsconsequently reportsFileIdMessage.type = 250(and other values from 248 through 253) as outsidefile; addfileto the ranged handling or encode its interval, then regenerate the catalog.AGENTS.md reference: AGENTS.md:L53-L58
Useful? React with 👍 / 👎.