Skip to main content

Keep an eSIM data journal you can check

Updated 7 min readMarkdown

Prepared with AI assistance for Portproof. The readings below are fictional. The Python checks run locally and do not test a handset, a mobile network or a provider's accounting.

A travel-data estimate starts with two readings that mean the same thing. Subtracting a phone's current-period counter from a plan's remaining allowance will still produce a number. It will not produce a useful usage sample.

Keep a small journal before building a dashboard or choosing a travel allowance. Record where a reading came from, which SIM and period it describes, and when its source was last updated. Then reject pairs whose meaning has changed.

Record the counter's identity

The iPhone usage reference describes current-period statistics, resetting them, and viewing usage for the selected data line. Pixel's usage guidance lets you choose a time period and notes that carrier measurements can differ from the phone's. These are reasons to retain measurement context, rather than assume every screen has the same counter.

Use short local labels such as travel-data; a journal does not need a phone number, account identifier, installation QR code or location history.

Field What it records
source phone or plan_used: a cumulative used-data counter. Remaining allowance is a different quantity.
line Your local label for the selected line. Keep different lines separate.
period The known measurement period or reset segment, such as sample-A.
captured_at When you copied the reading, with an explicit UTC offset.
snapshot_at The time the source reading actually describes, when known. Do not replace an old source timestamp with the time you refreshed the page.
value, unit The reported value and its known basis. The example accepts bytes, decimal MB or decimal GB explicitly.
reset Whether a reset occurred at this reading: yes or no.

For a phone counter you read directly, snapshot_at can be that observation's time. For a provider reading with an older update time, retain that older time. If you cannot establish the source time or unit basis, leave the pair unresolved. Do not label a screen's ambiguous “GB” as decimal merely to satisfy the script.

Select two readings before calculating a delta

Keep the full journal, but export exactly two selected rows for this check. The following sample.csv describes one fictional phone counter, on one line, within one period. Both units explicitly use decimal bytes; the example does not assert how a particular phone labels its units.

source,line,period,captured_at,snapshot_at,value,unit,reset
phone,travel-data,sample-A,2026-10-20T09:00:00Z,2026-10-20T09:00:00Z,1000,decimal_MB,no
phone,travel-data,sample-A,2026-10-22T09:00:00Z,2026-10-22T09:00:00Z,1.6,decimal_GB,no

The difference is 0.6 decimal GB between the two source snapshots. This is a calculation from invented inputs, not a measured consumption rate or a recommended allowance.

The checker below rejects a changed source, line or period; a reset at the end; an old or non-advancing source snapshot; and a decreasing counter. A blank counter remains unknown. A genuine zero delta requires two known, advancing snapshots with equal values.

A complete local checker

Save this as journal.py. It uses Python 3.10 or newer and only the standard library. It has no network, account or installation step.

"""Check two selected cumulative-usage readings; no network access."""
import argparse
import csv
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
import json
import re


UNITS = {"bytes": Decimal(1), "decimal_MB": Decimal(1000000),
         "decimal_GB": Decimal(1000000000)}


def timestamp(value):
    try:
        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
        if parsed.tzinfo is None or parsed.utcoffset() is None:
            raise ValueError
        return parsed.astimezone(timezone.utc)
    except (AttributeError, TypeError, ValueError):
        raise ValueError("invalid_time") from None


def reading(row):
    required = ("source", "line", "period", "captured_at", "snapshot_at",
                "value", "unit", "reset")
    if any(not isinstance(row.get(key), str) or not row[key].strip()
           for key in required):
        raise ValueError("missing_value")
    if row["source"] not in ("phone", "plan_used"):
        raise ValueError("unknown_source")
    if row["reset"] not in ("yes", "no"):
        raise ValueError("unknown_reset")
    if row["unit"] not in UNITS:
        raise ValueError("unknown_unit")
    number = row["value"].strip()
    # Bound significant digits so conversion and subtraction remain exact
    # within Decimal's default precision; never round an oversized input.
    if (not re.fullmatch(r"[0-9]+(?:\.[0-9]+)?", number)
            or len(number.replace(".", "")) > 18):
        raise ValueError("invalid_counter")
    try:
        count = Decimal(number) * UNITS[row["unit"]]
    except InvalidOperation:
        raise ValueError("invalid_counter") from None
    if count != count.to_integral_value():
        raise ValueError("fractional_byte")
    captured, snapshot = timestamp(row["captured_at"]), timestamp(row["snapshot_at"])
    if snapshot > captured:
        raise ValueError("future_snapshot")
    return captured, snapshot, count


def assess_pair(before, after, *, max_age_seconds):
    if type(max_age_seconds) is not int or max_age_seconds <= 0:
        raise ValueError("Choose a positive integer snapshot-age policy")
    try:
        a_captured, a_snapshot, a_count = reading(before)
        b_captured, b_snapshot, b_count = reading(after)
    except ValueError as error:
        return {"usable": False, "reason": str(error)}
    for key in ("source", "line", "period"):
        if before[key] != after[key]:
            return {"usable": False, "reason": "changed_" + key}
    if after["reset"] == "yes":
        return {"usable": False, "reason": "reset_at_end"}
    if b_captured <= a_captured or b_snapshot <= a_snapshot:
        return {"usable": False, "reason": "no_forward_interval"}
    if any((captured - snapshot).total_seconds() > max_age_seconds
           for captured, snapshot in ((a_captured, a_snapshot), (b_captured, b_snapshot))):
        return {"usable": False, "reason": "stale_when_captured"}
    if b_count < a_count:
        return {"usable": False, "reason": "counter_decreased"}
    return {"usable": True, "delta_decimal_GB": format(
        (b_count - a_count) / UNITS["decimal_GB"], "f"),
        "from_snapshot": a_snapshot.isoformat(),
        "to_snapshot": b_snapshot.isoformat()}


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("csv_file")
    parser.add_argument("--max-age-seconds", required=True, type=int)
    args = parser.parse_args()
    with open(args.csv_file, newline="", encoding="utf-8") as stream:
        rows = list(csv.DictReader(stream))
    if len(rows) != 2:
        parser.error("Export exactly two selected readings")
    print(json.dumps(assess_pair(rows[0], rows[1],
                               max_age_seconds=args.max_age_seconds), indent=2))

Run the fictional sample:

python3 journal.py sample.csv --max-age-seconds 300

The 300 is an example policy: each snapshot must have been no more than five minutes old when captured. It is not a provider refresh promise, and it does not make an old journal entry current today. Choose and record a suitable policy for your source instead of copying the number without a reason.

A usable result contains delta_decimal_GB and the two source timestamps. An unusable result contains a reason and no delta. The script does not sum different sources, estimate missing traffic, create a daily average or claim the result matches billable usage. Conversion is limited to explicitly named units and at most eighteen input digits; oversized or fractional-byte inputs are rejected rather than rounded into an apparently precise result.

Interpret a rejected pair

  • reset_at_end or counter_decreased: establish a new baseline. Do not turn the decrease into negative usage or assume a top-up erased earlier consumption.
  • changed_source, changed_line or changed_period: choose a genuinely comparable pair. A provider's cumulative used counter is not interchangeable with a phone's counter.
  • stale_when_captured or no_forward_interval: retain the uncertainty. Reading the same cached snapshot twice does not establish zero additional usage.
  • missing_value, unknown_unit or invalid_time: recover the missing context before calculating. Do not fill it with zero, a guessed unit or an assumed timezone.

This is a consistency check on the information you supplied. It cannot detect an unrecorded reset, a mislabelled line or a provider counter whose meaning changed without a new period label. Keep those limits visible with any exported result.

What was checked

On 23 October 2026, fifteen offline unittest cases passed with Python 3.12.14. They cover known unit conversion, valid zero, missing values, resets/decreases, changed identities, snapshot-age boundaries, duplicate and reversed times, timezone offsets, invalid counters and fractional bytes. The standalone CSV command was also run, and the complete displayed Python block was compared with the executed file.

These checks validate the local input rules, not device-counter accuracy, plan availability, network performance or the amount a provider will charge.

Once you have a representative comparable sample, use the eSIM data calculator to make your own trip assumptions explicit. If the sample includes laptop sharing, the hotspot guide explains how to avoid counting the same work twice. Coverage, validity and activation conditions still need their own plan checks.

No comments yet. Be the first to comment.