PIANO performance data parser - #159
Conversation
Move the TOML-emitting helpers out of make_performance_model.py into _performance_model_toml.py and replace write_legacy_performance_toml with a single write_performance_toml shared by every model type. Sections are now an ordered mapping of section name to table, so a model can emit more than the three legacy flight performance sections. Three additions prepare the writer for PIANO data without changing legacy output: crossover_altitude_m in the speed key order, a None guard on speed keys, and the PIANO column comments. Add tests/data/performance/legacy_golden.toml and a byte-identity regression test. The test was landed and confirmed green on the unmodified writer before the refactor, and stays green after it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01QymTe3DRs64MtZvFcDJ7Xu
PerformanceTableInput had nothing legacy-specific in it. Split it: TableInput carries the column name normalization, duplicate check and row size check; PerformanceTableInput adds only the required-column check. TableInput goes to performance/types.py rather than models/base.py. It is plain tabular data, and a parser that needs it should not have to import the model layer, which drags in the config system, the EDB reader and pandas. PerformanceTableInput stays in models/base.py, where the required-column rule belongs. Add TableInput.column() for lookup by column name. Keep tolerating rows with more data columns than labels, which PerformanceTable.from_input relies on when it truncates rows. Accept an empty table, which a parser can legitimately produce and which the row size checks previously hit with an IndexError. legacy.py re-exports PerformanceTableInput, so existing imports and the docs autoclass reference still resolve. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01QymTe3DRs64MtZvFcDJ7Xu
PIANO exports report mass in pounds, thrust and drag in pounds-force, and fuel flow in pounds per hour. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01QymTe3DRs64MtZvFcDJ7Xu
Parse PIANO cruise, climb and descent text exports into SI tables. Every column PIANO reports is kept except buffet onset and the NOx, HC and CO emission indices. Climb and descent true airspeed is derived from the airspeed schedule and the ISA standard atmosphere, reusing AEIC.utils.standard_atmosphere. Each block cross-checks the derived speed against its own distance and time, and its final cumulative burn against its header total. Both warn only. Cruise rows are keyed on (fl, mass, mach). A labelled reference Mach can land on the swept grid and disagree with it, so the swept row wins and the disagreeing columns are named in a warning. Relax SpeedData.cas_low and cas_high to optional, add crossover_altitude_m, and make Speeds.cruise optional. PIANO's cruise table states no CAS schedule and no single cruise Mach, and neither CAS field is read anywhere today. Add anonymized PIANO exports as test fixtures. Their numbers are dummy values and are internally inconsistent. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01QymTe3DRs64MtZvFcDJ7Xu
Add the fields a PIANO performance model file carries: the three phase tables, the cruise reference Mach table, the descent idle thrust table and an optional operating empty mass. Fix the model_type discriminator, which was Literal['Piano'] and so could never match. PerformanceModel.normalize_model_type lowercases model_type before discrimination, so a piano file failed to load with union_tag_invalid. bada.py and tasopt.py carry the same bug and are left alone here. empty_mass raises unless an operating empty mass was supplied: PIANO exports do not contain one, and unlike BADA the sweep gives no basis for deriving it. evaluate_impl raises; evaluation over the sweep comes later. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01QymTe3DRs64MtZvFcDJ7Xu
Factor the nine options every subcommand takes into a shared decorator, along with the APU check and LTO resolution both subcommands run. Flag names, types, defaults and help text are unchanged; only the order they list in --help changes, which now follows declaration order. The piano subcommand reads a set of PIANO exports and writes a model_type = "piano" file with five table sections. Cruise speed data is written only when --cruise-mach is given, so no cruise Mach assumption is baked into a file whose sweep covers many of them. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01QymTe3DRs64MtZvFcDJ7Xu
Cover the reader's parsing rules: block counts, the mass count mismatch error, both airspeed schedules and their overrides, a labelled Mach that lands on the swept grid, incomplete reference Mach groups, dropped zero-time rows, unit conversions on one row of each phase, rate-of-climb signs, row order and the non-zero Delta-ISA error. The duplicate-row disagreement path gets its own test built on a modified copy of the cruise fixture, because the fixture's own colliding rows agree on every column. Cover the subcommand end to end: the written file loads back as a PianoPerformanceModel, every emitted column survives the round trip, maximum_mass spans the three phase tables, empty_mass needs an operating empty mass, and cruise speed data appears only with --cruise-mach. The fixtures are anonymized dummy data whose numbers are internally inconsistent, so the tests assert structure and units only. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01QymTe3DRs64MtZvFcDJ7Xu
ian-ross
left a comment
There was a problem hiding this comment.
Just took a quick look at this. I know it's only a draft and you're likely to make changes, but I wanted to head off some stuff that you (or Claude) are doing that's not good.
| the five a performance table requires. Every column PIANO reports is kept | ||
| except buffet onset and the NOx, HC and CO emission indices. | ||
|
|
||
| Parsing never fabricates rows. A climb block that halts below its target |
There was a problem hiding this comment.
"Parsing never fabricates rows"? I would hope not...
| """Alt., Time, Dist., Burn, R.o.D., FN/eng.""" | ||
|
|
||
| _CRUISE_ROW_COLS = 15 | ||
| """Mass, Altitude, Mach and the discriminator, plus eleven swept values.""" |
There was a problem hiding this comment.
The 4th column of PIANO's cruise tables flags the row as coming from the Mach sweep (indicated by |), or a specific performance point (3 values: maxLim, maxSAR, 99%SAR).
I'll make that clearer in the comment.
I don't think I explicitly define the expected schema, so I could add that as a block comment at the top instead of piecing it together from the different expected columns.
There was a problem hiding this comment.
Yeah, that would probably be good. It gives you a natural place to define the term "discriminator", which you can then use freely in the rest of the code.
| PTF_FILE = TEST_DATA_DIR / 'verification' / 'legacy' / 'legacy_performance.PTF' | ||
|
|
||
|
|
||
| # Disable default configuration loading fixture just for this file. The |
There was a problem hiding this comment.
Nopey-nope-nope. Don't do this. This is coding agent trash. It's copied this from the test_config.py file, which is the only place where this should be done in tests.
| CLIMB_MASSES_KG = '54000,50000,46000,42000' | ||
|
|
||
|
|
||
| # Disable default configuration loading fixture just for this file. The |
| clear the singleton first. | ||
| """ | ||
| Config.reset() | ||
| return CliRunner().invoke( |
| ) | ||
|
|
||
|
|
||
| def load_model(out_file: Path, *extra_args: str) -> PianoPerformanceModel: |
There was a problem hiding this comment.
I think you need to make this some sort of cached fixture. You call it in a lot of tests below, and you're only going to be adding more tests, so calling it more, which is going to get slow. But I've now looked back at what you were doing, and I think you need to organize things somewhat for testing. Don't use Click's CLI runner for this. Design for testability instead.
(This is really one of those cases where using a coding agent gets very annoying. They think they have to test all the things, and they have no taste or discretion about where to set the testing boundaries. Then they see that there is some way to work around the default test configuration and they copy it and use it in a completely inappropriate place. Instead, think about where the test boundary should be: you do not need to be running CLI commands within tests. Click works.)
There was a problem hiding this comment.
That makes sense. I think refactoring the code separating the Click runner from the the actual parsing/writing would help remove the default_config fixture trick and avoid running Click commands in testing.
I am considering moving the logic from commands/make_performance_models.py to a new module e.g. performance/model_writer.py and only keep Click logic + Config.load() + light arg parsing in commands/make_performance_models.py.
performance/model_writer.py would then contain the writer function and two new functions build_*_performance_model(parsed_cli_args...) -> *PerformanceModel. Then the writer can have this signature: write_performance_toml(output_file, PerformanceModel) -> None.
The point of all this is that now we can test via build_*_performance_model(parsed_cli_args...) and inspect the performance model that's outputted directly instead of having to do CLI calls to run the parsing/writing.
Does this refactoring make sense and address the issue?
@click.option(
...
)
@click.pass_context
def piano(...):
# check apu
# resolve lto
# resolve speed schedule overrides
performance_model: PianoPerformanceModel = build_piano_performance_model(...)
write_performance_toml(output_file, performance_model)There was a problem hiding this comment.
Yes, that sounds much better. In general, pure functions are much easier to test (you build_*_performance_model would be pure), things that write files or do other kinds of mutations take a little more infrastructure to test (but write_performance_toml just writes a file, which is the easiest of these kinds of cases), and things that run whole external programs or try to do "clever stuff" are the worst (the test_config.py nastiness was supposed to be localized exactly to that one place where I was testing the configuration system itself — it's definitely not something that should be copied around and used in normal tests!).
There was a problem hiding this comment.
Sounds good, I'll implement this and ask you to take a another look then.
|
Oh yeah, to add to my review comments: in principle, this looks great, and it's a good first step to adding more and better performance models. We just need to wrangle the Claude. |
7466d88 to
101cb31
Compare
|
I've refactored the code such that:
On top of updating the doc, there are couple of decisions left which are more science related:
I think point 1. is the only functionality I would want decided/finalized before considering this to be done (barring code improvements). Point 2. and 3. could wait. |
Draft for now as I clean this up. It targets main, but we should switch to the develop branch on it's setup
This PR is part of #158 and implements a converter of PIANO performance outputs to AEIC
.tomlfiles that follow existing conventions + carry additional information not contained in.PTFfiles (most notably cruise performance is a function of(FL, mass, mach).This PR implements a stub of the
PianoPerformanceModelinperformance/piano.pybased on data stored in the.tomlbut does not implement the actual performance evaluation methodevaluate_impl(), this is for a future PR.PIANO output description
PIANO outputs with dummy data have been added to
tests/data/piano/and are representative of real outputs. Data is stored in 3 files, one for cruise, one for climb and one for descend. I recommend taking a look at these files first to make sense of the rest of the description here.Climb
PIANO climb outputs assume a fixed speed schedule with PIANO-default values: CAS=250 kts when
altitude < FL100, aircraft specific constant CAS forFL100 < altitude < crossover_altitude, and an aircraft specific constant Mach foraltitude > crossover_altitude.The table contains the following columns:
Altitude,time,distance,cumulative fuel burn,net thrust per engine,rate of climb,drag.This can be computed for a range of starting masses, such that there are multiple climb tables in a single climb file. However climb table headers which contain (starting mass, speed schedule...) only appear above a table when the cruise has reached its target altitude (which it does not always reach):
PianoOverridesdataclassCruise
PIANO computes a performance sweep at cruise as a function of
(FL, mass, mach)and provides:TAS,CAS,Drag,Max. Cruise Rating,L/D,FuelFlow,SFC,Specific Air Range,Max Climb Rating / engine,Rate of climb at Max climb rating for a fixed Mach,Rate of Climb at max climb rating for a fixed CAS(+ some others, I am only storing these for now).On top of these "parameter sweep" points, it also provides performance at notable operating points:
max specific air range,99% specific air range,maxLim. These points are appended at the end of a mach sweep: their(FL, mass, mach)coordinate may be repeated from the sweep but there is no guarantee that the values associated are the exact same as the ones from the sweep (probably some rounding in the PIANO output). Currently I overwrite this with data from the sweep but we could do the opposite.Descent
Very similar to climb, except that they in my experience always succeed such that the descent table header is always present and can be parsed to get the speed schedule and mass, simplifying the processing (no need for
PianoOverrides. PIANO also provides the altitude after which thrust is set to idle, this is also recorded in the.tomlfile if we ever have use for it.PIANO.toml schema
A PIANO based
.tomlfile looks like this (dummy numbers again):Code refactors
.tomlwriter functions from the legacy write code incommands/make_performance_models.pytocommands/_performance_models_toml.pyand only kept legacy specific and PIANO specific CLI entry points incommands/make_performance_models.pyPerformanceTableInputdata structure into a table specific class, and a performance table class. The newTableInputdataclass (inperformance/types.py) accepts empty tables and is responsible for normalizing columns, checking shape consistency and provides a column accessor. NowPerformanceTableInputinherits from it and only performs validation on the columns making sure they contain the minimum performance data required.These refactors do not change the behavior of the existing CLI command for
legacymodels as tested with the referencetests/data/performance/legacy_golden.tomlgenerated with the old code, andtests/test_make_performance_model.pywhich verifies that the new code leads to a byte identical file.New code to support PIANO
Mainly in
parsers/piano_reader.pyandtests/.More details to come
In progress
Code:
Modeling decisions:
PianoOverridesto only include climb masses)Speedvalue for a PIANO which does not have a set cruise machoew = min_mass / 1.2, whereas PIANO can provide the exact number (though it is not in the outputs we parse)